E-commerce

Gift Card

A gift-card configurator with a live card preview, a design swatch picker, and an amount selector.

Install

npx shadcn@latest add @paragon/gift-card

gift-card.tsx

"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>
  );
}