{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "wallet",
  "title": "Wallet",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Leather wallet with cards you can peek at and pull out.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "files": [
    {
      "path": "src/registry/components/wallet/wallet.tsx",
      "content": "\"use client\"\n\nimport {\n  Children,\n  createContext,\n  use,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\"\nimport type { ComponentProps, ReactNode, RefObject } from \"react\"\nimport { mergeProps } from \"@base-ui/react/merge-props\"\nimport { useRender } from \"@base-ui/react/use-render\"\nimport {\n  motion,\n  useAnimationControls,\n  useInView,\n  useReducedMotion,\n} from \"motion/react\"\nimport type {\n  LegacyAnimationControls,\n  TargetAndTransition,\n  Transition,\n} from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\n\n// 384x240 pocket with a thumb notch cut from the top edge.\nconst POCKET_PATH =\n  \"M24 0H140C158 0 164 32 192 32C220 32 226 0 244 0H360A24 24 0 0 1 384 24V216A24 24 0 0 1 360 240H24A24 24 0 0 1 0 216V24A24 24 0 0 1 24 0Z\"\n\n// Open along the top, where the cards go in.\nconst STITCH_PATH = \"M12 24V216A12 12 0 0 0 24 228H360A12 12 0 0 0 372 216V24\"\n\n// Distances are spacing steps of the stage, matching `h-56` on the card.\nconst CARD_HEIGHT = 56\nconst CARD_REVEAL = 9\nconst PEEK_REVEAL = 4\nconst PULL_REVEAL = 52\n\nconst ENTER_DELAY = 0.12\nconst ENTER_STAGGER = 0.08\nconst ENTER_SHINE_DELAY = 0.5\n\nconst PULL_SPRING: Transition = {\n  type: \"spring\",\n  visualDuration: 0.4,\n  bounce: 0.25,\n}\n\nconst SETTLE_SPRING: Transition = {\n  type: \"spring\",\n  visualDuration: 0.25,\n  bounce: 0,\n}\n\nconst REDUCED_TRANSITION: Transition = { duration: 0 }\n\n// `null` starts from the current scale, so a quick second pull doesn't jump.\nconst SQUEEZE = {\n  scaleX: [null, 1.006, 1],\n  scaleY: [null, 0.985, 1],\n  transition: { duration: 0.36, times: [0, 0.36, 1], ease: \"easeOut\" },\n} satisfies TargetAndTransition\n\ntype WithChildren<Props> = Omit<Props, \"children\"> & { children?: ReactNode }\n\n// Percent of the card's own height, so travel follows the rendered size.\nfunction getCardY(steps: number) {\n  return `${(-steps / CARD_HEIGHT) * 100}%`\n}\n\nfunction getTargetY(\n  index: number,\n  count: number,\n  activeIndex: number | null,\n  peekIndex: number | null\n) {\n  if (activeIndex !== null) {\n    return getCardY(index === activeIndex ? PULL_REVEAL : 0)\n  }\n\n  // Cards behind the peeked one rise with it, so none of them gets covered.\n  const isPeeking = peekIndex !== null && index <= peekIndex\n  return getCardY((count - index) * CARD_REVEAL + (isPeeking ? PEEK_REVEAL : 0))\n}\n\ntype WalletContextValue = {\n  activeIndex: number | null\n  peekIndex: number | null\n  isInView: boolean\n  isEntering: boolean\n  pocketControls: LegacyAnimationControls\n  selectCard: (index: number | null) => void\n  peekCard: (index: number | null) => void\n  finishEntering: () => void\n}\n\nconst WalletContext = createContext<WalletContextValue | null>(null)\n\nfunction useWallet() {\n  const context = use(WalletContext)\n  if (!context) throw new Error(\"Wallet parts must be used within <Wallet>\")\n  return context\n}\n\nconst WalletCardSlotContext = createContext<{\n  index: number\n  count: number\n} | null>(null)\n\ntype WalletCardContextValue = {\n  index: number\n  isActive: boolean\n  triggerRef: RefObject<HTMLButtonElement | null>\n}\n\nconst WalletCardContext = createContext<WalletCardContextValue | null>(null)\n\nfunction useWalletCard() {\n  const context = use(WalletCardContext)\n  if (!context) {\n    throw new Error(\"WalletCard parts must be used within <WalletCard>\")\n  }\n  return context\n}\n\nexport function Wallet({\n  className,\n  children,\n  ...props\n}: ComponentProps<\"div\">) {\n  const rootRef = useRef<HTMLDivElement>(null)\n  const isInView = useInView(rootRef, { once: true, amount: 0.5 })\n  const shouldReduceMotion = useReducedMotion()\n  const pocketControls = useAnimationControls()\n\n  const [activeIndex, setActiveIndex] = useState<number | null>(null)\n  const [peekIndex, setPeekIndex] = useState<number | null>(null)\n  const [hasEntered, setHasEntered] = useState(false)\n\n  useEffect(() => {\n    if (isInView && !shouldReduceMotion) pocketControls.start(SQUEEZE)\n  }, [isInView, pocketControls, shouldReduceMotion])\n\n  const context = useMemo(\n    () => ({\n      activeIndex,\n      peekIndex,\n      isInView,\n      isEntering: isInView && !hasEntered,\n      pocketControls,\n      // Any interaction ends the intro, so its stagger never holds a card back.\n      selectCard: (index: number | null) => {\n        setHasEntered(true)\n        setActiveIndex(index)\n        // A card that moved away leaves its hover behind under a still pointer.\n        setPeekIndex(null)\n        if (index !== null && !shouldReduceMotion) {\n          pocketControls.start(SQUEEZE)\n        }\n      },\n      peekCard: (index: number | null) => {\n        if (shouldReduceMotion) return\n        setHasEntered(true)\n        setPeekIndex(index)\n      },\n      finishEntering: () => setHasEntered(true),\n    }),\n    [\n      activeIndex,\n      peekIndex,\n      isInView,\n      hasEntered,\n      pocketControls,\n      shouldReduceMotion,\n    ]\n  )\n\n  return (\n    <WalletContext value={context}>\n      <div\n        ref={rootRef}\n        data-slot=\"wallet\"\n        className={cn(\"@container w-96 max-w-full select-none\", className)}\n        {...props}\n      >\n        {/* Every length inside is a spacing step, so this scales with the root.\n            Radii and type are `cqw`: lint folds spacing radii to fixed ones. */}\n        <div className=\"relative [--spacing:calc(100cqw/96)]\">{children}</div>\n      </div>\n    </WalletContext>\n  )\n}\n\nexport function WalletCards({\n  className,\n  children,\n  onPointerLeave,\n  ...props\n}: ComponentProps<\"ul\">) {\n  const { peekCard } = useWallet()\n  const cards = Children.toArray(children)\n\n  return (\n    // The height is the headroom a pulled card rises into.\n    <ul\n      role=\"list\"\n      data-slot=\"wallet-cards\"\n      className={cn(\"relative h-52\", className)}\n      onPointerLeave={(event) => {\n        onPointerLeave?.(event)\n        peekCard(null)\n      }}\n      {...props}\n    >\n      {cards.map((card, index) => (\n        <WalletCardSlotContext\n          key={index}\n          value={{ index, count: cards.length }}\n        >\n          {card}\n        </WalletCardSlotContext>\n      ))}\n    </ul>\n  )\n}\n\nexport function WalletCard({\n  className,\n  children,\n  onPointerMove,\n  ...props\n}: WithChildren<ComponentProps<typeof motion.li>>) {\n  const slot = use(WalletCardSlotContext)\n  if (!slot) throw new Error(\"WalletCard must be used within <WalletCards>\")\n\n  const { index, count } = slot\n  const {\n    activeIndex,\n    peekIndex,\n    isInView,\n    isEntering,\n    selectCard,\n    peekCard,\n    finishEntering,\n  } = useWallet()\n  const shouldReduceMotion = useReducedMotion()\n  const triggerRef = useRef<HTMLButtonElement>(null)\n\n  const isActive = index === activeIndex\n  const isFront = index === count - 1\n\n  useEffect(() => {\n    if (!isActive) return\n\n    function handleKeyDown(event: KeyboardEvent) {\n      if (event.key !== \"Escape\") return\n\n      // The card's content turns inert, which would drop focus to the body.\n      const trigger = triggerRef.current\n      if (trigger?.closest(\"li\")?.contains(document.activeElement)) {\n        trigger.focus()\n      }\n      selectCard(null)\n    }\n\n    function handlePointerDown(event: PointerEvent) {\n      const cards = triggerRef.current?.closest('[data-slot=\"wallet-cards\"]')\n      const card = (event.target as Element).closest(\n        '[data-slot=\"wallet-card\"]'\n      )\n      if (!card || !cards?.contains(card)) selectCard(null)\n    }\n\n    document.addEventListener(\"keydown\", handleKeyDown)\n    document.addEventListener(\"pointerdown\", handlePointerDown)\n    return () => {\n      document.removeEventListener(\"keydown\", handleKeyDown)\n      document.removeEventListener(\"pointerdown\", handlePointerDown)\n    }\n  }, [isActive, selectCard])\n\n  const context = useMemo(\n    () => ({ index, isActive, triggerRef }),\n    [index, isActive]\n  )\n\n  function getCardTransition(): Transition {\n    if (shouldReduceMotion) return REDUCED_TRANSITION\n\n    if (isEntering) {\n      return { ...PULL_SPRING, delay: ENTER_DELAY + index * ENTER_STAGGER }\n    }\n\n    return activeIndex === null ? SETTLE_SPRING : PULL_SPRING\n  }\n\n  return (\n    <WalletCardContext value={context}>\n      <motion.li\n        data-slot=\"wallet-card\"\n        data-active={isActive || undefined}\n        className={cn(\n          \"absolute inset-x-4 top-full h-56 overflow-hidden rounded-[3cqw] bg-card text-card-foreground shadow-[0_--spacing(-1.5)_--spacing(4)_--spacing(-2)_rgb(0_0_0/0.35),inset_0_1px_0_rgb(255_255_255/0.18)] inset-ring inset-ring-white/10\",\n          className\n        )}\n        initial={false}\n        animate={{\n          y: isInView\n            ? getTargetY(index, count, activeIndex, peekIndex)\n            : getCardY(0),\n        }}\n        transition={getCardTransition()}\n        onAnimationComplete={() => {\n          if (isFront && isEntering) finishEntering()\n        }}\n        // Not enter: a settling card leaves a stale hover under a still pointer.\n        onPointerMove={(event) => {\n          onPointerMove?.(event)\n          if (event.pointerType !== \"touch\") peekCard(index)\n        }}\n        {...props}\n      >\n        <span\n          aria-hidden\n          className=\"absolute inset-0 bg-linear-160 from-white/15 to-transparent to-45%\"\n        />\n        {children}\n        {isFront && <WalletCardShine />}\n      </motion.li>\n    </WalletCardContext>\n  )\n}\n\nfunction WalletCardShine() {\n  const { isInView } = useWallet()\n  const shouldReduceMotion = useReducedMotion()\n  const controls = useAnimationControls()\n\n  useEffect(() => {\n    if (!isInView || shouldReduceMotion) return\n\n    controls.start({\n      x: [\"-170%\", \"430%\"],\n      opacity: [0, 1, 1, 0],\n      transition: {\n        delay: ENTER_SHINE_DELAY,\n        duration: 0.8,\n        ease: \"easeInOut\",\n      },\n    })\n  }, [controls, isInView, shouldReduceMotion])\n\n  return (\n    <motion.span\n      aria-hidden\n      className=\"pointer-events-none absolute inset-y-0 left-0 w-[46%] bg-linear-to-r from-transparent via-white/50 to-transparent\"\n      initial={{ x: \"-170%\", skewX: -16, opacity: 0 }}\n      animate={controls}\n    />\n  )\n}\n\nexport function WalletCardHeader({\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"wallet-card-header\"\n      className={cn(\n        \"flex h-9 items-center justify-between gap-2 px-3.5\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport function WalletCardTitle({\n  className,\n  children,\n  render,\n  ...props\n}: useRender.ComponentProps<\"h3\">) {\n  const { index, isActive, triggerRef } = useWalletCard()\n  const { selectCard, peekCard } = useWallet()\n\n  return useRender({\n    defaultTagName: \"h3\",\n    render,\n    state: { slot: \"wallet-card-title\" },\n    props: mergeProps<\"h3\">(\n      {\n        className: cn(\n          \"min-w-0 text-[3.75cqw] leading-normal font-medium\",\n          className\n        ),\n        children: (\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-expanded={isActive}\n            className=\"group/wallet-trigger block max-w-full cursor-pointer truncate outline-none\"\n            onClick={() => selectCard(isActive ? null : index)}\n            onFocus={(event) => {\n              if (event.target.matches(\":focus-visible\")) peekCard(index)\n            }}\n            onBlur={() => peekCard(null)}\n          >\n            {/* Stretched over the card, so inner links stay outside the button. */}\n            <span\n              aria-hidden\n              className=\"absolute inset-0 rounded-[3cqw] group-focus-visible/wallet-trigger:inset-ring-2 group-focus-visible/wallet-trigger:inset-ring-current\"\n            />\n            {children}\n          </button>\n        ),\n      },\n      props\n    ),\n  })\n}\n\nexport function WalletCardDescription({\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"wallet-card-description\"\n      className={cn(\n        \"flex shrink-0 items-center gap-1.5 font-mono text-[2.5cqw] leading-none tracking-wider uppercase opacity-70 [&_svg]:size-[3.75cqw]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport function WalletCardContent({\n  className,\n  ...props\n}: ComponentProps<\"div\">) {\n  const { isActive } = useWalletCard()\n\n  return (\n    // Clicks fall through to the trigger, except on inner links and buttons.\n    <div\n      data-slot=\"wallet-card-content\"\n      inert={!isActive}\n      className={cn(\n        \"pointer-events-none absolute inset-x-3.5 bottom-7 [&_a,&_button]:pointer-events-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport function WalletPocket({\n  className,\n  children,\n  ...props\n}: WithChildren<ComponentProps<typeof motion.div>>) {\n  const { pocketControls } = useWallet()\n  const id = useId()\n  const grainId = `${id}-grain`\n  const shadeId = `${id}-shade`\n\n  return (\n    <motion.div\n      data-slot=\"wallet-pocket\"\n      className={cn(\n        \"pointer-events-none relative z-10 h-60 origin-bottom will-change-transform\",\n        className\n      )}\n      animate={pocketControls}\n      {...props}\n    >\n      <svg\n        aria-hidden\n        viewBox=\"0 0 384 240\"\n        className=\"absolute inset-0 size-full overflow-visible drop-shadow-[0_--spacing(-0.75)_--spacing(1.5)_rgb(0_0_0/0.15)]\"\n      >\n        <defs>\n          {/* Lit noise becomes alpha, so the grain takes the fill's color. */}\n          <filter id={grainId} x=\"0\" y=\"0\" width=\"100%\" height=\"100%\">\n            <feTurbulence\n              type=\"fractalNoise\"\n              baseFrequency=\"0.7\"\n              numOctaves=\"3\"\n              seed=\"7\"\n            />\n            <feDiffuseLighting surfaceScale=\"0.6\" lightingColor=\"#fff\">\n              <feDistantLight azimuth=\"235\" elevation=\"55\" />\n            </feDiffuseLighting>\n            <feColorMatrix\n              type=\"matrix\"\n              values=\"0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  -1 0 0 0 1\"\n            />\n            <feComposite in=\"SourceGraphic\" operator=\"in\" />\n          </filter>\n\n          <linearGradient id={shadeId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n            <stop stopColor=\"white\" stopOpacity=\"0.1\" />\n            <stop offset=\"0.3\" stopColor=\"white\" stopOpacity=\"0\" />\n            <stop offset=\"1\" stopColor=\"black\" stopOpacity=\"0.06\" />\n          </linearGradient>\n        </defs>\n\n        {/* Only the pocket shape blocks the cards, so the notch stays clickable. */}\n        <path d={POCKET_PATH} className=\"pointer-events-auto fill-surface\" />\n        <path\n          d={POCKET_PATH}\n          filter={`url(#${grainId})`}\n          className=\"fill-foreground opacity-15\"\n        />\n        <path d={POCKET_PATH} fill={`url(#${shadeId})`} />\n        <path\n          d={POCKET_PATH}\n          className=\"fill-none stroke-foreground/10\"\n          vectorEffect=\"non-scaling-stroke\"\n        />\n\n        <path\n          d={STITCH_PATH}\n          fill=\"none\"\n          strokeWidth={2}\n          className=\"stroke-foreground/10\"\n        />\n        <path\n          d={STITCH_PATH}\n          fill=\"none\"\n          strokeWidth={1.5}\n          strokeDasharray=\"5 5\"\n          strokeLinecap=\"round\"\n          className=\"stroke-foreground/25\"\n        />\n      </svg>\n\n      {children}\n    </motion.div>\n  )\n}\n\nexport function WalletLabel({ className, ...props }: ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"wallet-label\"\n      className={cn(\n        \"absolute bottom-7 left-7 flex items-center gap-2 text-[5.25cqw] leading-none font-medium text-foreground/80 [&_svg]:h-[4.5cqw] [&_svg]:w-auto\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n",
      "type": "registry:component",
      "target": "@components/wallet.tsx"
    }
  ],
  "docs": "https://chanhdai.com/components/wallet",
  "categories": [
    "illustrations"
  ],
  "type": "registry:component"
}