A gift-card configurator with a live card preview, a design swatch picker, and an amount selector.
npx shadcn@latest add @paragon/gift-card"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { Gift } from "lucide-react";
import { cn } from "@/lib/utils";
export interface GiftCardDesign {
id: string;
label: string;
/** CSS background for the card face and swatch. */
background: string;
/** Foreground text color on the card face. */
foreground?: string;
}
export interface GiftCardProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
designs: GiftCardDesign[];
amounts: number[];
designValue?: string;
defaultDesign?: string;
amountValue?: number;
defaultAmount?: number;
onDesignChange?: (id: string) => void;
onAmountChange?: (amount: number) => void;
brand?: string;
currency?: string;
locale?: Intl.LocalesArgument;
/** Disables selection-ring slide; selection snaps. */
static?: boolean;
}
/**
* A gift-card configurator: a live card preview updates as the shopper picks a
* design (swatch row, shared selection ring slides via layoutId) and an amount
* (chip row). The preview's face cross-fades its background; the amount is
* tabular. Reduced motion snaps ring and fade.
*/
export function GiftCard({
designs,
amounts,
designValue,
defaultDesign,
amountValue,
defaultAmount,
onDesignChange,
onAmountChange,
brand = "Aera",
currency = "USD",
locale = "en-US",
static: isStatic = false,
className,
...props
}: GiftCardProps) {
const reduced = useReducedMotion();
const ringId = React.useId();
const amtId = React.useId();
const [uDesign, setUDesign] = React.useState(defaultDesign ?? designs[0]?.id);
const design = designValue ?? uDesign;
const [uAmount, setUAmount] = React.useState(defaultAmount ?? amounts[0]);
const amount = amountValue ?? uAmount;
const format = React.useMemo(
() =>
new Intl.NumberFormat(locale, {
style: "currency",
currency,
maximumFractionDigits: 0,
}),
[locale, currency],
);
const selectDesign = (id: string) => {
if (designValue === undefined) setUDesign(id);
onDesignChange?.(id);
};
const selectAmount = (a: number) => {
if (amountValue === undefined) setUAmount(a);
onAmountChange?.(a);
};
const current = designs.find((d) => d.id === design) ?? designs[0];
return (
<div
className={cn("flex w-full max-w-sm flex-col gap-4", className)}
{...props}
>
{/* Preview */}
<div className="relative aspect-[1.586] w-full overflow-hidden rounded-2xl shadow-border">
{designs.map((d) => (
<span
key={d.id}
aria-hidden
className="absolute inset-0 transition-opacity duration-200 ease-out motion-reduce:transition-none"
style={{ background: d.background, opacity: d.id === design ? 1 : 0 }}
/>
))}
<div
className="relative flex h-full flex-col justify-between p-5"
style={{ color: current?.foreground ?? "#fff" }}
>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold tracking-wide">
{brand}
</span>
<Gift className="size-5 opacity-90" aria-hidden />
</div>
<div>
<p className="text-xs uppercase tracking-wider opacity-80">
Gift card
</p>
<p className="text-3xl font-semibold tabular-nums">
{format.format(amount)}
</p>
</div>
</div>
</div>
{/* Design swatches */}
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">
Design
</p>
<div role="radiogroup" aria-label="Gift card design" className="flex gap-2">
{designs.map((d) => {
const selected = d.id === design;
return (
<button
key={d.id}
type="button"
role="radio"
aria-checked={selected}
aria-label={d.label}
onClick={() => selectDesign(d.id)}
className="relative size-9 shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<span
aria-hidden
className="block size-full rounded-full shadow-border"
style={{ background: d.background }}
/>
{selected && (
<motion.span
layoutId={
isStatic || reduced ? undefined : `gc-ring-${ringId}`
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
aria-hidden
className="absolute -inset-1 rounded-full shadow-[0_0_0_2px_var(--color-foreground)]"
/>
)}
</button>
);
})}
</div>
</div>
{/* Amount chips */}
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">
Amount
</p>
<div
role="radiogroup"
aria-label="Gift card amount"
className="grid grid-cols-4 gap-2"
>
{amounts.map((a) => {
const selected = a === amount;
return (
<button
key={a}
type="button"
role="radio"
aria-checked={selected}
onClick={() => selectAmount(a)}
className={cn(
"relative rounded-lg py-2 text-sm font-medium tabular-nums outline-none transition-colors duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
selected
? "text-primary-foreground"
: "text-foreground shadow-border hover:shadow-border-hover",
)}
>
{selected && (
<motion.span
layoutId={
isStatic || reduced ? undefined : `gc-amt-${amtId}`
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
aria-hidden
className="absolute inset-0 rounded-lg bg-primary"
/>
)}
<span className="relative">{format.format(a)}</span>
</button>
);
})}
</div>
</div>
</div>
);
}