An RMA card to select items, pick a reason and refund method, with a refund total that tweens as items change.
npx shadcn@latest add @paragon/return-request"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, RotateCcw, CreditCard, Wallet } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ReturnItem {
id: string;
name: string;
variant?: string;
price: number;
hue?: number;
}
export interface ReturnRequestProps
extends Omit<React.ComponentProps<"div">, "onSubmit"> {
items: ReturnItem[];
reasons?: string[];
currency?: string;
locale?: Intl.LocalesArgument;
onSubmit?: (payload: {
items: string[];
reason: string;
refund: "original" | "credit";
}) => void;
}
function placeholder(hue: number) {
return `linear-gradient(135deg, hsl(${hue} 55% 82%), hsl(${(hue + 40) % 360} 60% 66%))`;
}
const DEFAULT_REASONS = [
"Wrong size or fit",
"Item defective or damaged",
"Not as described",
"Changed my mind",
];
/**
* A return (RMA) card: check the items to send back, pick a reason, choose a
* refund method, and see the running refund total tween. The submit button
* enables only when the form is valid and flips to a confirmed state. Money is
* tabular. Reduced motion drops the total tween and check pop.
*/
export function ReturnRequest({
items,
reasons = DEFAULT_REASONS,
currency = "USD",
locale = "en-US",
onSubmit,
className,
...props
}: ReturnRequestProps) {
const reduced = useReducedMotion();
const [selected, setSelected] = React.useState<Set<string>>(new Set());
const [reason, setReason] = React.useState("");
const [refund, setRefund] = React.useState<"original" | "credit">("credit");
const [done, setDone] = React.useState(false);
const format = React.useMemo(
() => new Intl.NumberFormat(locale, { style: "currency", currency }),
[locale, currency],
);
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const total = items
.filter((i) => selected.has(i.id))
.reduce((sum, i) => sum + i.price, 0);
const bonus = refund === "credit" ? total * 0.1 : 0;
const valid = selected.size > 0 && reason !== "" && !done;
return (
<div
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 shadow-border",
className,
)}
{...props}
>
<div className="mb-3 flex items-center gap-2">
<RotateCcw className="size-4 text-muted-foreground" aria-hidden />
<h3 className="text-sm font-medium text-card-foreground">
Start a return
</h3>
</div>
{/* Items */}
<fieldset className="flex flex-col gap-2">
<legend className="sr-only">Select items to return</legend>
{items.map((item) => {
const checked = selected.has(item.id);
return (
<label
key={item.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-lg p-2 transition-colors duration-150",
checked ? "bg-accent/50" : "hover:bg-accent/30",
)}
>
<span
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-md transition-[background-color,box-shadow] duration-150 ease-out",
checked
? "bg-foreground text-background"
: "shadow-[inset_0_0_0_1.5px_var(--color-border)]",
)}
>
<Check
className={cn(
"size-3.5 transition-[scale,opacity] duration-150 ease-out",
checked ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
aria-hidden
/>
</span>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => toggle(item.id)}
/>
<span
aria-hidden
className="size-9 shrink-0 rounded-md"
style={{ background: placeholder(item.hue ?? 40) }}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-card-foreground">
{item.name}
</span>
{item.variant && (
<span className="block text-xs text-muted-foreground">
{item.variant}
</span>
)}
</span>
<span className="text-sm tabular-nums text-card-foreground">
{format.format(item.price)}
</span>
</label>
);
})}
</fieldset>
{/* Reason */}
<label className="mt-4 block">
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">
Reason
</span>
<select
value={reason}
onChange={(e) => setReason(e.target.value)}
className="h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm text-foreground outline-none transition-[border-color,box-shadow] duration-150 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25"
>
<option value="" disabled>
Select a reason…
</option>
{reasons.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
{/* Refund method */}
<div className="mt-4">
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">
Refund to
</span>
<div role="radiogroup" aria-label="Refund method" className="grid grid-cols-2 gap-2">
{(
[
{
id: "credit",
label: "Store credit",
Icon: Wallet,
hint: "+10% bonus",
},
{
id: "original",
label: "Original payment",
Icon: CreditCard,
hint: undefined,
},
] as const
).map(({ id, label, Icon, hint }) => {
const on = refund === id;
return (
<button
key={id}
type="button"
role="radio"
aria-checked={on}
onClick={() => setRefund(id)}
className={cn(
"flex flex-col items-start gap-1 rounded-lg p-2.5 text-left outline-none transition-[box-shadow] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring",
on ? "shadow-[0_0_0_2px_var(--color-foreground)]" : "shadow-border hover:shadow-border-hover",
)}
>
<Icon className="size-4 text-muted-foreground" aria-hidden />
<span className="text-xs font-medium text-card-foreground">
{label}
</span>
{hint && (
<span className="text-[11px] font-medium text-success">
{hint}
</span>
)}
</button>
);
})}
</div>
</div>
{/* Total + submit */}
<div className="mt-4 flex items-center justify-between border-t border-border pt-3">
<span className="text-sm text-muted-foreground">Estimated refund</span>
<TotalTween
value={total + bonus}
format={format}
reduced={!!reduced}
/>
</div>
<button
type="button"
disabled={!valid}
onClick={() => {
setDone(true);
onSubmit?.({
items: Array.from(selected),
reason,
refund,
});
}}
className={cn(
"pressable mt-3 flex h-9 w-full items-center justify-center gap-2 rounded-lg text-sm font-medium transition-[background-color,color] duration-150 ease-out disabled:pointer-events-none disabled:opacity-50",
done
? "bg-success text-success-foreground"
: "bg-primary text-primary-foreground hover:bg-primary/90",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={done ? "done" : "submit"}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="flex items-center gap-2"
>
{done ? (
<>
<Check className="size-4" aria-hidden />
Return requested
</>
) : (
"Request return label"
)}
</motion.span>
</AnimatePresence>
</button>
</div>
);
}
function TotalTween({
value,
format,
reduced,
}: {
value: number;
format: Intl.NumberFormat;
reduced: boolean;
}) {
const [display, setDisplay] = React.useState(value);
const from = React.useRef(value);
React.useEffect(() => {
if (reduced) {
setDisplay(value);
from.current = value;
return;
}
const start = performance.now();
const a = from.current;
const b = value;
let raf = 0;
const tick = (t: number) => {
const k = Math.min(1, (t - start) / 300);
const eased = 1 - Math.pow(1 - k, 3);
setDisplay(a + (b - a) * eased);
if (k < 1) raf = requestAnimationFrame(tick);
else from.current = b;
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [value, reduced]);
return (
<span className="text-lg font-semibold tabular-nums text-card-foreground">
{format.format(display)}
</span>
);
}