{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A WalletCard component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { Eye, EyeOff } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n} from \"motion/react\";\nimport type { KeyboardEvent, PointerEvent, ReactNode } from \"react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\n\nconst STACK_OFFSET_DEFAULT = 13;\nconst STACK_SCALE_STEP = 0.05;\nconst MAX_STACK_DEPTH = 3;\n/** Cards held in a hand tip away from you — this is that tilt. */\nconst RECEDE_ROTATE_X = 7;\n/** Selected card comes toward the viewer instead of just growing. */\nconst ACTIVE_LIFT_Z = 36;\nconst PERSPECTIVE = 1400;\nconst AVATAR_OVERFLOW_THRESHOLD = 4;\nconst AVATAR_OVERLAP = 12;\nconst BLUR_AMOUNT = 6;\nconst CHAR_STAGGER = 0.018;\nconst INITIALS_MAX = 2;\nconst MASK_LENGTH = 6;\nconst SHEEN_HOME_X = 28;\nconst SHEEN_HOME_Y = 18;\nconst PERCENT = 100;\n\nconst SPRING_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.25,\n  type: \"spring\" as const,\n};\nconst LIFT_TRANSITION = {\n  bounce: 0.1,\n  duration: 0.3,\n  type: \"spring\" as const,\n};\nconst SHEEN_SPRING = { damping: 26, mass: 0.4, stiffness: 140 };\n\n/**\n * Fine grain over the whole card. Real cards are printed, not painted —\n * a flat gradient is the single biggest tell that a card is a CSS box.\n */\nconst NOISE_BACKGROUND =\n  \"url(\\\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E\\\")\";\n\nconst DEFAULT_CARD_SURFACE =\n  \"bg-[linear-gradient(145deg,oklch(0.42_0.02_264),oklch(0.24_0.015_264))]\";\n\nexport type WalletCardNetwork = \"visa\" | \"mastercard\" | \"amex\" | \"generic\";\n\nexport interface WalletAccount {\n  balance: number;\n  currency: string;\n  /** Printed expiry, e.g. `\"08/29\"`. */\n  expiry?: string;\n  /** Tailwind classes for the card surface (gradient, colour, pattern). */\n  gradient?: string;\n  /** Embossed cardholder name. */\n  holder?: string;\n  id: string;\n  label: string;\n  last4?: string;\n  network?: WalletCardNetwork;\n}\n\nexport interface WalletMember {\n  avatar?: string;\n  id: string;\n  name: string;\n}\n\nexport interface WalletCardProps {\n  accounts: WalletAccount[];\n  actions?: ReactNode;\n  activeId?: string;\n  className?: string;\n  hidden?: boolean;\n  members?: WalletMember[];\n  onActiveChange?: (id: string) => void;\n  onHiddenChange?: (hidden: boolean) => void;\n  stackOffset?: number;\n}\n\nconst getInitials = (name: string) =>\n  name\n    .split(\" \")\n    .filter(Boolean)\n    .slice(0, INITIALS_MAX)\n    .map((part) => part[0]?.toUpperCase())\n    .join(\"\");\n\n/** EMV chip, contact grid and all. */\nconst EmvChip = () => {\n  const gradientId = useId();\n\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-[26px] w-[34px] shrink-0\"\n      fill=\"none\"\n      viewBox=\"0 0 34 26\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <title>Chip</title>\n      <defs>\n        <linearGradient id={gradientId} x1=\"0\" x2=\"1\" y1=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor=\"#f4e3ab\" />\n          <stop offset=\"45%\" stopColor=\"#c9a544\" />\n          <stop offset=\"100%\" stopColor=\"#8f6f22\" />\n        </linearGradient>\n      </defs>\n      <rect\n        fill={`url(#${gradientId})`}\n        height=\"25\"\n        rx=\"4.5\"\n        stroke=\"rgba(0,0,0,0.22)\"\n        width=\"33\"\n        x=\"0.5\"\n        y=\"0.5\"\n      />\n      <g stroke=\"rgba(0,0,0,0.3)\" strokeWidth=\"1\">\n        <path d=\"M0 8.5H11\" />\n        <path d=\"M0 17.5H11\" />\n        <path d=\"M23 8.5H34\" />\n        <path d=\"M23 17.5H34\" />\n        <path d=\"M11 3.5V22.5\" />\n        <path d=\"M23 3.5V22.5\" />\n        <path d=\"M11 13H23\" />\n      </g>\n    </svg>\n  );\n};\n\n/** Contactless payment glyph — three nested arcs. */\nconst ContactlessGlyph = () => (\n  <svg\n    aria-hidden=\"true\"\n    className=\"h-[18px] w-[18px] shrink-0 text-white/65\"\n    fill=\"none\"\n    stroke=\"currentColor\"\n    strokeLinecap=\"round\"\n    strokeWidth=\"1.7\"\n    viewBox=\"0 0 24 24\"\n    xmlns=\"http://www.w3.org/2000/svg\"\n  >\n    <title>Contactless</title>\n    <path d=\"M5.5 9.4a5.4 5.4 0 0 1 0 5.2\" />\n    <path d=\"M9.2 7a9.4 9.4 0 0 1 0 10\" />\n    <path d=\"M12.9 4.7a13.4 13.4 0 0 1 0 14.6\" />\n  </svg>\n);\n\nconst NetworkMark = ({ network }: { network: WalletCardNetwork }) => {\n  if (network === \"mastercard\") {\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"h-6 w-10 shrink-0\"\n        fill=\"none\"\n        viewBox=\"0 0 40 24\"\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        <title>Mastercard</title>\n        <circle cx=\"15\" cy=\"12\" fill=\"#eb001b\" r=\"8.5\" />\n        <circle cx=\"25\" cy=\"12\" fill=\"#f79e1b\" r=\"8.5\" />\n        <path\n          d=\"M20 5.1a8.5 8.5 0 0 0 0 13.8 8.5 8.5 0 0 0 0-13.8Z\"\n          fill=\"#ff5f00\"\n        />\n      </svg>\n    );\n  }\n\n  if (network === \"amex\") {\n    return (\n      <span className=\"rounded-[3px] bg-white/90 px-1.5 py-0.5 font-bold text-[#2e77bc] text-[9px] uppercase tracking-[0.06em]\">\n        Amex\n      </span>\n    );\n  }\n\n  if (network === \"visa\") {\n    return (\n      <span className=\"font-bold text-[15px] text-white italic leading-none tracking-[-0.02em]\">\n        VISA\n      </span>\n    );\n  }\n\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className=\"h-5 w-8 shrink-0 text-white/55\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"1.4\"\n      viewBox=\"0 0 32 20\"\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <title>Card network</title>\n      <rect height=\"13\" rx=\"2.5\" width=\"19\" x=\"1\" y=\"3.5\" />\n      <path d=\"M1 8h19\" />\n    </svg>\n  );\n};\n\nconst BalanceDisplay = ({\n  currency,\n  hidden,\n  shouldReduceMotion,\n  value,\n}: {\n  currency: string;\n  hidden: boolean;\n  shouldReduceMotion: boolean;\n  value: number;\n}) => {\n  const formatter = new Intl.NumberFormat(undefined, {\n    currency,\n    style: \"currency\",\n  });\n  const formatted = formatter.format(value);\n  // Keeping the currency symbol while masking the digits makes the hidden\n  // state read as a deliberate choice rather than missing data.\n  const symbol =\n    formatter.formatToParts(value).find((part) => part.type === \"currency\")\n      ?.value ?? \"\";\n  const characters = hidden\n    ? [...symbol.split(\"\"), ...Array.from({ length: MASK_LENGTH }, () => \"•\")]\n    : formatted.split(\"\");\n\n  return (\n    <span\n      aria-label={hidden ? \"Balance hidden\" : formatted}\n      className={cn(\n        \"flex font-semibold text-[26px] text-white tabular-nums leading-none\",\n        hidden && \"text-white/75 tracking-[0.06em]\"\n      )}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {characters.map((char, index) => (\n          <motion.span\n            animate={{ filter: \"blur(0px)\", opacity: 1 }}\n            aria-hidden=\"true\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { filter: `blur(${BLUR_AMOUNT}px)`, opacity: 0 }\n            }\n            initial={\n              shouldReduceMotion\n                ? { opacity: 1 }\n                : { filter: `blur(${BLUR_AMOUNT}px)`, opacity: 0 }\n            }\n            key={`${hidden}-${index}-${char}`}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : { ...SPRING_TRANSITION, delay: index * CHAR_STAGGER }\n            }\n          >\n            {char}\n          </motion.span>\n        ))}\n      </AnimatePresence>\n    </span>\n  );\n};\n\ninterface AccountCardProps {\n  account: WalletAccount;\n  buttonRef: (node: HTMLButtonElement | null) => void;\n  hidden: boolean;\n  isActive: boolean;\n  onSelect: () => void;\n  rank: number;\n  shouldReduceMotion: boolean;\n  stackOffset: number;\n  zIndex: number;\n}\n\nconst AccountCard = ({\n  account,\n  buttonRef,\n  hidden,\n  isActive,\n  onSelect,\n  rank,\n  shouldReduceMotion,\n  stackOffset,\n  zIndex,\n}: AccountCardProps) => {\n  const pointerX = useMotionValue(SHEEN_HOME_X);\n  const pointerY = useMotionValue(SHEEN_HOME_Y);\n  const sheenX = useSpring(pointerX, SHEEN_SPRING);\n  const sheenY = useSpring(pointerY, SHEEN_SPRING);\n  const sheen = useMotionTemplate`radial-gradient(120% 90% at ${sheenX}% ${sheenY}%, rgba(255,255,255,0.30), rgba(255,255,255,0.06) 45%, transparent 72%)`;\n  const isHoverCapableRef = useRef(false);\n\n  useEffect(() => {\n    const mediaQuery = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    isHoverCapableRef.current = mediaQuery.matches;\n\n    const handleChange = (event: MediaQueryListEvent) => {\n      isHoverCapableRef.current = event.matches;\n    };\n\n    mediaQuery.addEventListener(\"change\", handleChange);\n    return () => mediaQuery.removeEventListener(\"change\", handleChange);\n  }, []);\n\n  const handlePointerMove = (event: PointerEvent<HTMLButtonElement>) => {\n    if (!isHoverCapableRef.current || shouldReduceMotion) {\n      return;\n    }\n    const rect = event.currentTarget.getBoundingClientRect();\n    pointerX.set(((event.clientX - rect.left) / rect.width) * PERCENT);\n    pointerY.set(((event.clientY - rect.top) / rect.height) * PERCENT);\n  };\n\n  const handlePointerLeave = () => {\n    pointerX.set(SHEEN_HOME_X);\n    pointerY.set(SHEEN_HOME_Y);\n  };\n\n  const network = account.network ?? \"generic\";\n\n  return (\n    <motion.div\n      animate={\n        shouldReduceMotion\n          ? { rotateX: 0, scale: 1, y: 0, z: 0 }\n          : {\n              rotateX: isActive ? 0 : RECEDE_ROTATE_X,\n              scale: 1 - rank * STACK_SCALE_STEP,\n              y: rank * stackOffset,\n              z: isActive ? ACTIVE_LIFT_Z : 0,\n            }\n      }\n      className=\"col-start-1 row-start-1\"\n      initial={false}\n      // Presentational so the listbox still directly owns its options — the\n      // motion layer must not sit in the accessibility tree between them.\n      role=\"presentation\"\n      style={{ transformStyle: \"preserve-3d\", zIndex }}\n      transition={shouldReduceMotion ? { duration: 0 } : LIFT_TRANSITION}\n    >\n      <button\n        aria-selected={isActive}\n        className={cn(\n          \"relative block aspect-[1.586] w-full cursor-pointer overflow-hidden rounded-[16px] text-left outline-none ring-offset-2 ring-offset-background focus-visible:ring-2 focus-visible:ring-ring\",\n          account.gradient ?? DEFAULT_CARD_SURFACE,\n          isActive\n            ? \"shadow-[0_2px_4px_rgba(0,0,0,0.10),0_14px_28px_-10px_rgba(0,0,0,0.34),0_34px_60px_-24px_rgba(0,0,0,0.40)]\"\n            : \"shadow-[0_1px_2px_rgba(0,0,0,0.10),0_10px_20px_-10px_rgba(0,0,0,0.28)]\"\n        )}\n        onClick={onSelect}\n        onPointerLeave={handlePointerLeave}\n        onPointerMove={handlePointerMove}\n        ref={buttonRef}\n        role=\"option\"\n        tabIndex={isActive ? 0 : -1}\n        type=\"button\"\n      >\n        {/* Printed grain. */}\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 opacity-[0.14] mix-blend-overlay\"\n          style={{ backgroundImage: NOISE_BACKGROUND }}\n        />\n        {/* Specular highlight that tracks the pointer. */}\n        <motion.span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 mix-blend-soft-light\"\n          style={{ backgroundImage: sheen }}\n        />\n        {/* Bright top edge — light catching the card's lip. */}\n        <span\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 rounded-[16px] ring-1 ring-white/12 ring-inset\"\n        />\n\n        <span className=\"relative flex h-full w-full flex-col justify-between p-5\">\n          <span className=\"flex items-start justify-between gap-3\">\n            <span className=\"flex items-center gap-2.5\">\n              <EmvChip />\n              <ContactlessGlyph />\n            </span>\n            <span className=\"font-medium text-[11px] text-white/70 tracking-[0.02em]\">\n              {account.label}\n            </span>\n          </span>\n\n          <span className=\"block\">\n            <BalanceDisplay\n              currency={account.currency}\n              hidden={hidden}\n              shouldReduceMotion={shouldReduceMotion}\n              value={account.balance}\n            />\n          </span>\n\n          <span className=\"block font-medium text-[15px] text-white/85 tabular-nums tracking-[0.14em]\">\n            &bull;&bull;&bull;&bull; &bull;&bull;&bull;&bull;\n            &bull;&bull;&bull;&bull; {account.last4 ?? \"••••\"}\n          </span>\n\n          <span className=\"flex items-end justify-between gap-3\">\n            <span className=\"flex min-w-0 items-end gap-5\">\n              <span className=\"block min-w-0\">\n                <span className=\"block text-[8px] text-white/45 uppercase tracking-[0.14em]\">\n                  Card holder\n                </span>\n                <span className=\"block truncate font-medium text-[12px] text-white/90 uppercase tracking-[0.05em]\">\n                  {account.holder ?? \"—\"}\n                </span>\n              </span>\n              <span className=\"block shrink-0\">\n                <span className=\"block text-[8px] text-white/45 uppercase tracking-[0.14em]\">\n                  Valid thru\n                </span>\n                <span className=\"block font-medium text-[12px] text-white/90 tabular-nums tracking-[0.05em]\">\n                  {account.expiry ?? \"——/——\"}\n                </span>\n              </span>\n            </span>\n            <NetworkMark network={network} />\n          </span>\n        </span>\n      </button>\n    </motion.div>\n  );\n};\n\nexport default function WalletCard({\n  accounts,\n  actions,\n  activeId: controlledActiveId,\n  className,\n  hidden: controlledHidden,\n  members = [],\n  onActiveChange,\n  onHiddenChange,\n  stackOffset = STACK_OFFSET_DEFAULT,\n}: WalletCardProps) {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const [internalActiveId, setInternalActiveId] = useState(accounts[0]?.id);\n  const [internalHidden, setInternalHidden] = useState(false);\n  const [overflowOpen, setOverflowOpen] = useState(false);\n  const optionRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const activeId = controlledActiveId ?? internalActiveId;\n  const hidden = controlledHidden ?? internalHidden;\n\n  const setActiveId = (id: string) => {\n    if (onActiveChange) {\n      onActiveChange(id);\n    } else {\n      setInternalActiveId(id);\n    }\n  };\n\n  const setHidden = (next: boolean) => {\n    if (onHiddenChange) {\n      onHiddenChange(next);\n    } else {\n      setInternalHidden(next);\n    }\n  };\n\n  const activeIndex = accounts.findIndex((account) => account.id === activeId);\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (accounts.length === 0) {\n      return;\n    }\n\n    let nextIndex = activeIndex;\n    if (event.key === \"ArrowRight\" || event.key === \"ArrowDown\") {\n      event.preventDefault();\n      nextIndex = (activeIndex + 1 + accounts.length) % accounts.length;\n    } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      nextIndex = (activeIndex - 1 + accounts.length) % accounts.length;\n    } else {\n      return;\n    }\n\n    const next = accounts[nextIndex];\n    if (next) {\n      setActiveId(next.id);\n      optionRefs.current[nextIndex]?.focus();\n    }\n  };\n\n  let rankCounter = 0;\n  const visibleMembers = members.slice(0, AVATAR_OVERFLOW_THRESHOLD);\n  const overflowMembers = members.slice(AVATAR_OVERFLOW_THRESHOLD);\n\n  return (\n    <div className={cn(\"flex flex-col gap-5\", className)}>\n      <div\n        aria-label=\"Cards\"\n        className=\"relative grid\"\n        onKeyDown={handleKeyDown}\n        role=\"listbox\"\n        style={{\n          marginBottom: MAX_STACK_DEPTH * stackOffset,\n          perspective: shouldReduceMotion ? undefined : PERSPECTIVE,\n          transformStyle: \"preserve-3d\",\n        }}\n      >\n        {accounts.map((account, index) => {\n          const isActive = account.id === activeId;\n          const rank = isActive ? 0 : ++rankCounter;\n          const cappedRank = Math.min(rank, MAX_STACK_DEPTH);\n\n          return (\n            <AccountCard\n              account={account}\n              buttonRef={(node) => {\n                optionRefs.current[index] = node;\n              }}\n              hidden={hidden}\n              isActive={isActive}\n              key={account.id}\n              onSelect={() => setActiveId(account.id)}\n              rank={cappedRank}\n              shouldReduceMotion={shouldReduceMotion}\n              stackOffset={stackOffset}\n              zIndex={accounts.length - rank}\n            />\n          );\n        })}\n      </div>\n\n      <div className=\"flex items-center justify-between gap-3\">\n        <div className=\"flex items-center\">\n          {visibleMembers.map((member, index) => (\n            <div\n              aria-label={member.name}\n              className=\"flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-muted font-medium text-xs ring-2 ring-background\"\n              key={member.id}\n              role=\"img\"\n              style={{\n                marginLeft: index === 0 ? 0 : -AVATAR_OVERLAP,\n                zIndex: visibleMembers.length - index,\n              }}\n            >\n              {member.avatar ? (\n                <img\n                  alt=\"\"\n                  aria-hidden=\"true\"\n                  className=\"h-full w-full object-cover\"\n                  src={member.avatar}\n                />\n              ) : (\n                <span aria-hidden=\"true\">{getInitials(member.name)}</span>\n              )}\n            </div>\n          ))}\n          {overflowMembers.length > 0 ? (\n            <button\n              aria-controls=\"wallet-card-overflow-list\"\n              aria-expanded={overflowOpen}\n              aria-label={`Show ${overflowMembers.length} more members`}\n              className=\"ease ml-1 flex h-8 min-w-[32px] cursor-pointer items-center justify-center rounded-full bg-secondary px-2 font-medium text-xs outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\"\n              onClick={() => setOverflowOpen((open) => !open)}\n              type=\"button\"\n            >\n              +{overflowMembers.length}\n            </button>\n          ) : null}\n        </div>\n        {actions}\n      </div>\n\n      <AnimatePresence>\n        {overflowOpen && overflowMembers.length > 0 ? (\n          <motion.ul\n            animate={{ opacity: 1, y: 0 }}\n            aria-label=\"Additional members\"\n            className=\"flex flex-col gap-1 rounded-lg border bg-background p-2 text-sm\"\n            exit={\n              shouldReduceMotion\n                ? { opacity: 0, transition: { duration: 0 } }\n                : { opacity: 0, y: -4 }\n            }\n            id=\"wallet-card-overflow-list\"\n            initial={\n              shouldReduceMotion ? { opacity: 1, y: 0 } : { opacity: 0, y: -4 }\n            }\n            transition={\n              shouldReduceMotion ? { duration: 0 } : SPRING_TRANSITION\n            }\n          >\n            {overflowMembers.map((member) => (\n              <li key={member.id}>{member.name}</li>\n            ))}\n          </motion.ul>\n        ) : null}\n      </AnimatePresence>\n\n      <SmoothButton\n        aria-label={hidden ? \"Show balance\" : \"Hide balance\"}\n        aria-pressed={hidden}\n        className=\"w-fit\"\n        onClick={() => setHidden(!hidden)}\n        prefix={\n          hidden ? <EyeOff aria-hidden=\"true\" /> : <Eye aria-hidden=\"true\" />\n        }\n        shape=\"pill\"\n        size=\"sm\"\n        variant=\"outline\"\n      >\n        {hidden ? \"Show balance\" : \"Hide balance\"}\n      </SmoothButton>\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/wallet-card/index.tsx","type":"registry:ui"}],"name":"wallet-card","registryDependencies":["https://smoothui.dev/r/smooth-button.json"],"title":"Wallet Card","type":"registry:ui"}