{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"wallet-cards","type":"registry:component","title":"Wallet cards","description":"Tap the cards peeking out of the home screen and the front one lifts into a carousel as the page changes around it; close it and it drops back into the stack.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/wallet-cards.tsx","type":"registry:component","content":"\"use client\";\n\nimport {\n  createContext,\n  use,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  AnimatePresence,\n  LayoutGroup,\n  motion,\n  useScroll,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After Benji Taylor's money app (https://x.com/benjitaylor): the cards wait\n// in a stack peeking out of the home screen. Tap them and the front one lifts\n// out and straightens into a carousel while the page around it changes over;\n// close it and whichever card you were on drops back to the front of the\n// stack.\n\ntype CardId = \"virtual\" | \"physical\";\n\nconst CARDS: {\n  id: CardId;\n  name: string;\n  last4: string;\n  number: string;\n  cashback: { lifetime: string; week: string };\n  days: { day: string; rows: Txn[] }[];\n}[] = [\n  {\n    id: \"virtual\",\n    name: \"Virtual card\",\n    last4: \"4791\",\n    number: \"4791 2286 0553 4791\",\n    cashback: { lifetime: \"$96.34\", week: \"$1.53\" },\n    days: [\n      {\n        day: \"5 Oct\",\n        rows: [\n          {\n            name: \"Bluestone Lane\",\n            kind: \"Coffee\",\n            amount: \"$12.40\",\n            back: \"$0.37\",\n          },\n          { name: \"Uber\", kind: \"Travel\", amount: \"$23.80\", back: \"$0.71\" },\n        ],\n      },\n      {\n        day: \"4 Oct\",\n        rows: [\n          { name: \"Figma\", kind: \"Software\", amount: \"$15.00\", back: \"$0.45\" },\n        ],\n      },\n    ],\n  },\n  {\n    id: \"physical\",\n    name: \"Physical card\",\n    last4: \"8456\",\n    number: \"5214 9031 7720 8456\",\n    cashback: { lifetime: \"$41.20\", week: \"$4.21\" },\n    days: [\n      {\n        day: \"3 Oct\",\n        rows: [\n          {\n            name: \"Whole Foods\",\n            kind: \"Groceries\",\n            amount: \"$86.20\",\n            back: \"$2.59\",\n          },\n          { name: \"Shell\", kind: \"Fuel\", amount: \"$54.10\", back: \"$1.62\" },\n        ],\n      },\n      {\n        day: \"1 Oct\",\n        rows: [\n          {\n            name: \"Blue Bottle\",\n            kind: \"Coffee\",\n            amount: \"$6.75\",\n            back: \"$0.20\",\n          },\n        ],\n      },\n    ],\n  },\n];\n\ntype Txn = { name: string; kind: string; amount: string; back: string };\n\n// Screen sizes, in px. The screen gives up width on a narrow phone, and\n// everything card-shaped is worked out from whatever width it has, so the\n// carousel stays centred and the flight lands true at any size.\nconst SCREEN_W = 360;\nconst SCREEN_H = 740;\n// Room either side of a card in the carousel, where the next one peeks.\nconst SIDE = 32;\nconst GAP = 12;\n\ntype Geometry = {\n  cardW: number;\n  cardH: number;\n  step: number;\n  // Whether the card can fly between the two screens. Not when the whole\n  // thing is drawn scaled down (an index card): the flight is measured on\n  // screen but moved in unscaled pixels, so it would land in the wrong\n  // place. Scaled, the screens simply cross-fade.\n  fly: boolean;\n};\nfunction geometry(width: number, fly = true): Geometry {\n  const cardW = width - SIDE * 2;\n  // A real card's proportions.\n  return { cardW, cardH: Math.round(cardW / 1.6), step: cardW + GAP, fly };\n}\nconst Geo = createContext<Geometry>(geometry(SCREEN_W));\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The card's flight: long enough to follow, with a little settle.\nconst FLIGHT = { type: \"spring\", visualDuration: 0.55, bounce: 0.1 } as const;\n// When a flight has landed for good, overshoot included, in ms.\nconst LANDED = 750;\n\nexport function WalletCards({ className }: { className?: string }) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const group = useId();\n  const [own, setOwn] = useState<\"home\" | \"cards\">(\"home\");\n  const [show, setShow] = useState<\"home\" | \"cards\">(\"home\");\n  const view = play ? show : own;\n  const [active, setActive] = useState(0);\n  const scroller = useRef<HTMLDivElement>(null);\n  const setView = play ? setShow : setOwn;\n  const screen = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(SCREEN_W);\n  const [scaled, setScaled] = useState(false);\n  const geo = geometry(width, !scaled);\n\n  // Layout width, not on-screen width: in a scaled index card the two\n  // differ, and the cards are laid out in the former. The difference is\n  // also how it knows it's been scaled.\n  useLayoutEffect(() => {\n    const el = screen.current;\n    if (!el) return;\n    const measure = () => {\n      setWidth(el.offsetWidth);\n      const drawn = el.getBoundingClientRect().width;\n      setScaled(Math.abs(drawn / el.offsetWidth - 1) > 0.02);\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  const flight = reduceMotion ? { duration: 0 } : FLIGHT;\n\n  // The card's show: open the cards, swipe across, close on the other one.\n  useEffect(() => {\n    if (!play) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    let round = 0;\n    const loop = () => {\n      const to = round % 2 === 0 ? 1 : 0;\n      timers.push(setTimeout(() => setShow(\"cards\"), 700));\n      timers.push(\n        setTimeout(\n          () =>\n            scroller.current?.scrollTo({\n              left: to * geometry(scroller.current.offsetWidth).step,\n              behavior: \"smooth\",\n            }),\n          2000,\n        ),\n      );\n      timers.push(setTimeout(() => setShow(\"home\"), 3400));\n      timers.push(\n        setTimeout(() => {\n          round += 1;\n          loop();\n        }, 4600),\n      );\n    };\n    loop();\n    return () => {\n      timers.forEach(clearTimeout);\n      setShow(\"home\");\n    };\n  }, [play]);\n\n  const front = CARDS[active];\n  const back = CARDS[1 - active];\n\n  return (\n    <LayoutGroup id={group}>\n      <Geo value={geo}>\n        <div\n          ref={screen}\n          className={cn(\n            \"relative isolate max-w-full overflow-hidden rounded-[48px] bg-background shadow-raised\",\n            className,\n          )}\n          style={{ width: SCREEN_W, height: SCREEN_H }}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\" && view === \"cards\") setView(\"home\");\n          }}\n        >\n          <StatusBar />\n          <Header view={view} onClose={() => setView(\"home\")} />\n\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {view === \"home\" ? (\n              <motion.div\n                key=\"home\"\n                className=\"absolute inset-x-0 top-[96px] bottom-0\"\n              >\n                <Home\n                  front={front.id}\n                  back={back.id}\n                  flight={flight}\n                  reduceMotion={reduceMotion}\n                  onOpen={() => setView(\"cards\")}\n                />\n              </motion.div>\n            ) : (\n              <motion.div\n                key=\"cards\"\n                className=\"absolute inset-x-0 top-[96px] bottom-0\"\n              >\n                <Cards\n                  active={active}\n                  onActive={setActive}\n                  scroller={scroller}\n                  flight={flight}\n                  reduceMotion={reduceMotion}\n                />\n              </motion.div>\n            )}\n          </AnimatePresence>\n\n          <TabBar />\n        </div>\n      </Geo>\n    </LayoutGroup>\n  );\n}\n\n// Content that isn't a card leaves and arrives around the card's flight.\nconst leaveUp = (reduce: boolean) =>\n  reduce\n    ? { opacity: 0, transition: { duration: 0.12 } }\n    : {\n        opacity: 0,\n        y: -28,\n        filter: \"blur(4px)\",\n        transition: { duration: 0.24, ease: EASE_OUT },\n      };\nconst arrive = (reduce: boolean, i: number) => ({\n  initial: reduce ? { opacity: 0 } : { opacity: 0, y: 26, filter: \"blur(4px)\" },\n  animate: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: {\n      duration: 0.42,\n      ease: EASE_OUT,\n      delay: reduce ? 0 : 0.14 + i * 0.045,\n    },\n  },\n  exit: reduce\n    ? { opacity: 0, transition: { duration: 0.12 } }\n    : {\n        opacity: 0,\n        y: 24,\n        filter: \"blur(4px)\",\n        transition: { duration: 0.2, ease: EASE_OUT },\n      },\n});\n\nfunction Home({\n  front,\n  back,\n  flight,\n  reduceMotion,\n  onOpen,\n}: {\n  front: CardId;\n  back: CardId;\n  flight: object;\n  reduceMotion: boolean;\n  onOpen: () => void;\n}) {\n  const { cardW, cardH, fly } = use(Geo);\n  const fade = (i: number) => ({\n    initial: reduceMotion\n      ? { opacity: 0 }\n      : { opacity: 0, y: -24, filter: \"blur(4px)\" },\n    animate: {\n      opacity: 1,\n      y: 0,\n      filter: \"blur(0px)\",\n      transition: {\n        duration: 0.4,\n        ease: EASE_OUT,\n        delay: reduceMotion ? 0 : 0.12 + i * 0.04,\n      },\n    },\n    exit: leaveUp(reduceMotion),\n  });\n\n  return (\n    <div className=\"relative h-full px-[18px]\">\n      <motion.div {...fade(0)}>\n        <p className=\"flex items-center gap-1 text-[13px] text-muted-foreground\">\n          Total balance\n          <Glyph d=\"M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z\" />\n        </p>\n        <p className=\"mt-1 text-[40px] leading-none font-semibold tracking-[-0.03em] tabular-nums\">\n          $1,279.97\n        </p>\n        <p className=\"mt-2 text-[13px] text-muted-foreground\">Your money is protected</p>\n      </motion.div>\n\n      <motion.div {...fade(1)} className=\"mt-5 grid grid-cols-4 gap-2\">\n        {ACTIONS.map(({ label, d }) => (\n          <span\n            key={label}\n            className=\"flex h-[58px] flex-col items-center justify-center gap-1 rounded-[16px] bg-muted text-[12px] font-medium\"\n          >\n            <Glyph d={d} className=\"size-[17px]\" />\n            {label}\n          </span>\n        ))}\n      </motion.div>\n\n      <motion.div {...fade(2)} className=\"mt-2 grid grid-cols-2 gap-2\">\n        <div className=\"rounded-[18px] bg-muted p-3.5\">\n          <p className=\"text-[14px] font-medium\">Earnings</p>\n          <Row label=\"Lifetime\" value=\"$94.33\" />\n          <Row\n            label=\"This month\"\n            value=\"$3.00\"\n            tone=\"text-green-600 dark:text-green-400\"\n          />\n        </div>\n        <div className=\"rounded-[18px] bg-muted p-3.5\">\n          <p className=\"text-[14px] font-medium\">Activity</p>\n          <Row\n            label=\"Maya\"\n            value=\"+$24.00\"\n            avatar=\"oklch(0.78 0.11 60)\"\n            tone=\"text-green-600 dark:text-green-400\"\n          />\n          <Row\n            label=\"Arjun\"\n            value=\"+$8.50\"\n            avatar=\"oklch(0.72 0.1 250)\"\n            tone=\"text-green-600 dark:text-green-400\"\n          />\n        </div>\n      </motion.div>\n\n      {/* The stack: both cards, the one you were last on in front. */}\n      <button\n        type=\"button\"\n        onClick={onOpen}\n        aria-label=\"Open cards\"\n        className=\"group/stack absolute inset-x-0 top-[298px] h-[112px] outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:-outline-offset-4 focus-visible:outline-foreground\"\n      >\n        <span\n          className=\"absolute top-0 left-1/2 block transition-[translate] duration-300 ease-out group-hover/stack:-translate-y-1.5\"\n          style={{ width: cardW, marginLeft: -cardW / 2 }}\n        >\n          {/* Only the front card travels. The one behind it doesn't fly in\n              from wherever it sat in the carousel (off screen, as often as\n              not); it settles in behind the stack as the front card lands. */}\n          <motion.div\n            key={back}\n            className=\"absolute top-0 left-0\"\n            style={{ width: cardW, height: cardH }}\n            initial={\n              reduceMotion\n                ? { opacity: 0, rotate: 3.5, x: 8, y: -2 }\n                : { opacity: 0, scale: 0.94, rotate: 3.5, x: 8, y: 10 }\n            }\n            // Tucked behind, turned a little the other way, so both read\n            // as a stack without either leaving the screen.\n            animate={{\n              opacity: 1,\n              scale: 1,\n              rotate: 3.5,\n              x: 8,\n              y: -2,\n              transition: {\n                duration: 0.45,\n                ease: EASE_OUT,\n                delay: reduceMotion ? 0 : 0.2,\n              },\n            }}\n            // Leaving, it drops away with the sheet rather than hanging\n            // in the middle of the screen while the page changes.\n            exit={\n              reduceMotion\n                ? { opacity: 0, transition: { duration: 0.12 } }\n                : {\n                    opacity: 0,\n                    y: 40,\n                    scale: 0.96,\n                    transition: { duration: 0.22, ease: EASE_OUT },\n                  }\n            }\n          >\n            <CardFace id={back} />\n          </motion.div>\n          <motion.div\n            key={front}\n            layoutId={fly ? `${front}-card` : undefined}\n            layoutCrossfade={false}\n            className=\"absolute top-0 left-0\"\n            style={{ width: cardW, height: cardH }}\n            initial={fly ? false : { opacity: 0, rotate: -1.5, x: -4, y: 34 }}\n            animate={{ opacity: 1, rotate: -1.5, x: -4, y: 14 }}\n            exit={\n              fly\n                ? undefined\n                : { opacity: 0, y: 40, transition: { duration: 0.2 } }\n            }\n            transition={\n              fly ? flight : { duration: 0.4, ease: EASE_OUT, delay: 0.12 }\n            }\n          >\n            <CardFace id={front} />\n          </motion.div>\n        </span>\n      </button>\n\n      {/* The rest of home slides in over the bottom of the stack. */}\n      <motion.div\n        className=\"absolute inset-x-0 top-[398px] bottom-0 overflow-y-auto overscroll-contain rounded-t-[30px] bg-background px-[18px] pt-3 pb-20 shadow-[0_-10px_24px_-14px_rgb(0_0_0/0.25)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 60 }}\n        animate={{\n          opacity: 1,\n          y: 0,\n          transition: {\n            duration: 0.42,\n            ease: EASE_OUT,\n            delay: reduceMotion ? 0 : 0.16,\n          },\n        }}\n        exit={\n          reduceMotion\n            ? { opacity: 0 }\n            : {\n                opacity: 0,\n                y: 90,\n                transition: { duration: 0.26, ease: EASE_OUT },\n              }\n        }\n      >\n        <div className=\"flex items-center gap-3 rounded-[18px] bg-muted p-3\">\n          <span className=\"grid size-9 place-items-center rounded-[10px] bg-green-500 text-white\">\n            <Glyph\n              d=\"M2 5.5h12v6H2z M8 10a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z\"\n              className=\"size-[17px]\"\n            />\n          </span>\n          <span>\n            <span className=\"block text-[14px] font-medium\">\n              Set up direct deposit\n            </span>\n            <span className=\"block text-[12px] text-muted-foreground\">\n              Get paid up to two days early\n            </span>\n          </span>\n        </div>\n        <div className=\"mt-2 grid grid-cols-2 gap-2\">\n          <Account\n            name=\"Yash\"\n            apy=\"6.00% APY\"\n            amount=\"$1,279.97\"\n            tint=\"oklch(0.78 0.11 60)\"\n          />\n          <Account\n            name=\"Savings\"\n            apy=\"4.10% APY\"\n            amount=\"$0\"\n            tint=\"oklch(0.72 0.1 250)\"\n          />\n        </div>\n      </motion.div>\n    </div>\n  );\n}\n\nfunction Cards({\n  active,\n  onActive,\n  scroller,\n  flight,\n  reduceMotion,\n}: {\n  active: number;\n  onActive: (i: number) => void;\n  scroller: React.RefObject<HTMLDivElement | null>;\n  flight: object;\n  reduceMotion: boolean;\n}) {\n  const { cardW, cardH, step, fly } = use(Geo);\n  const { scrollX } = useScroll({ container: scroller });\n  const card = CARDS[active];\n  // While the card is flying in, the carousel can't be a scroll box: a\n  // scroll box clips, and would slice the card mid-flight. So it starts as\n  // a plain row shifted to the card you opened on, and becomes scrollable,\n  // at that same spot, once the card has landed.\n  const [settled, setSettled] = useState(reduceMotion);\n  const [opening] = useState(active);\n\n  useEffect(() => {\n    if (settled) return;\n    const timer = setTimeout(() => setSettled(true), LANDED);\n    return () => clearTimeout(timer);\n  }, [settled]);\n\n  // Lands scrolling on the card you opened, and keeps whichever card you're\n  // on centred if the width changes, a phone turning sideways say.\n  const current = useRef(active);\n  useEffect(() => {\n    current.current = active;\n  }, [active]);\n  useLayoutEffect(() => {\n    if (settled) {\n      scroller.current?.scrollTo({\n        left: current.current * step,\n        behavior: \"instant\",\n      });\n    }\n  }, [settled, step, scroller]);\n\n  return (\n    <div className=\"relative flex h-full flex-col\">\n      {/* Above what rises in below it, so the card flies over, not under. */}\n      <motion.div\n        ref={scroller}\n        layoutScroll\n        className={cn(\n          \"relative z-10 flex shrink-0 overscroll-x-contain pt-1 pb-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n          settled\n            ? \"snap-x snap-mandatory overflow-x-auto\"\n            : \"overflow-visible\",\n        )}\n        style={{ gap: GAP, paddingInline: SIDE, scrollPaddingInline: SIDE }}\n        onScroll={(e) => {\n          if (!settled) return;\n          const i = Math.round(e.currentTarget.scrollLeft / step);\n          if (i !== active && i >= 0 && i < CARDS.length) onActive(i);\n        }}\n      >\n        {CARDS.map((c, i) => (\n          <Slide\n            key={c.id}\n            index={i}\n            scrollX={scrollX}\n            still={settled ? null : opening}\n            reduceMotion={reduceMotion}\n          >\n            <motion.div\n              // Both cards keep an identity for the whole visit (Motion only\n              // sets one up when a card first appears). Only the front of\n              // the stack has a partner, so only the card you're on flies;\n              // its neighbour has nowhere to go and leaves with the page.\n              layoutId={fly ? `${c.id}-card` : undefined}\n              // Both ends show the same card, so a crossfade between them\n              // only reads as the card going see-through mid-flight.\n              layoutCrossfade={false}\n              style={{ width: cardW, height: cardH }}\n              initial={\n                i === opening\n                  ? fly || reduceMotion\n                    ? false\n                    : { opacity: 0, y: 30 }\n                  : reduceMotion\n                    ? false\n                    : { opacity: 0, x: 24 }\n              }\n              animate={{\n                rotate: 0,\n                x: 0,\n                y: 0,\n                opacity: 1,\n                transition:\n                  i === opening && fly\n                    ? flight\n                    : {\n                        duration: 0.45,\n                        ease: EASE_OUT,\n                        delay: i === opening ? 0.1 : 0.25,\n                      },\n              }}\n              transition={flight}\n            >\n              <CardFace id={c.id} details />\n            </motion.div>\n          </Slide>\n        ))}\n      </motion.div>\n\n      {/* The rest of the page scrolls on its own, clear of the tab bar. */}\n      <div className=\"min-h-0 flex-1 overflow-y-auto overscroll-contain px-[18px] pb-24 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">\n        <motion.div\n          {...arrive(reduceMotion, 0)}\n          className=\"mx-auto flex h-8 w-fit items-center gap-1.5 rounded-full bg-muted pr-2.5 pl-1 text-[13px] font-medium\"\n        >\n          <span\n            className=\"size-6 rounded-full\"\n            style={{ background: \"oklch(0.78 0.11 60)\" }}\n          />\n          Yash\n          <Glyph d=\"m5 6.5 3 3 3-3\" className=\"size-3.5 text-muted-foreground\" />\n        </motion.div>\n\n        <motion.div\n          {...arrive(reduceMotion, 1)}\n          className=\"mt-3 grid h-11 place-items-center rounded-full bg-muted text-[15px] font-medium\"\n        >\n          Manage card\n        </motion.div>\n\n        <motion.div\n          {...arrive(reduceMotion, 2)}\n          className=\"mt-2 grid grid-cols-2 gap-2\"\n        >\n          <div className=\"rounded-[18px] bg-muted p-3.5\">\n            <p className=\"text-[14px] font-medium\">Cashback</p>\n            <Swap id={card.id}>\n              <Row label=\"Lifetime\" value={card.cashback.lifetime} />\n              <Row\n                label=\"This week\"\n                value={card.cashback.week}\n                tone=\"text-sky-600 dark:text-sky-400\"\n              />\n            </Swap>\n          </div>\n          <div className=\"relative overflow-hidden rounded-[18px] bg-muted p-3.5\">\n            <p className=\"relative z-10 text-[14px] font-medium\">Find ATMs</p>\n            <MiniMap />\n          </div>\n        </motion.div>\n\n        <motion.div {...arrive(reduceMotion, 3)} className=\"mt-4\">\n          <Swap id={card.id}>\n            {card.days.map(({ day, rows }) => (\n              <div key={day} className=\"mb-3\">\n                <p className=\"mb-1 text-[13px] font-medium text-muted-foreground\">{day}</p>\n                {rows.map((t) => (\n                  <div key={t.name} className=\"flex items-center gap-3 py-1.5\">\n                    <span className=\"grid size-9 shrink-0 place-items-center rounded-full bg-muted\">\n                      <Glyph\n                        d=\"M2 4.5h12v8H2z M2 7h12\"\n                        className=\"size-[15px]\"\n                      />\n                    </span>\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"block truncate text-[14px] font-medium\">\n                        {t.name}\n                      </span>\n                      <span className=\"block text-[12px] text-muted-foreground\">\n                        {t.kind}\n                      </span>\n                    </span>\n                    <span className=\"text-right\">\n                      <span className=\"block text-[14px] font-medium tabular-nums\">\n                        {t.amount}\n                      </span>\n                      <span className=\"block text-[12px] text-sky-600 tabular-nums dark:text-sky-400\">\n                        {t.back}\n                      </span>\n                    </span>\n                  </div>\n                ))}\n              </div>\n            ))}\n          </Swap>\n        </motion.div>\n      </div>\n    </div>\n  );\n}\n\n// A carousel slot: the card in the middle is full size, its neighbours sit\n// back a touch, following the scroll exactly.\nfunction Slide({\n  index,\n  scrollX,\n  still,\n  reduceMotion,\n  children,\n}: {\n  index: number;\n  scrollX: MotionValue<number>;\n  // Before the carousel can scroll: which card it's resting on.\n  still: number | null;\n  reduceMotion: boolean;\n  children: React.ReactNode;\n}) {\n  const { cardW, step } = use(Geo);\n  const scale = useTransform(\n    scrollX,\n    [(index - 1) * step, index * step, (index + 1) * step],\n    reduceMotion ? [1, 1, 1] : [0.94, 1, 0.94],\n  );\n  const resting = reduceMotion || index === still ? 1 : 0.94;\n  return (\n    // Shifted, not scrolled, until the carousel can scroll. A plain style,\n    // so it's gone in the same commit that turns scrolling on: snap points\n    // are worked out from the shifted boxes, and a shift that lingered a\n    // frame would snap the carousel back to the first card.\n    <div\n      className=\"relative shrink-0 snap-center\"\n      style={{\n        // The card you opened on flies over its neighbour, as it sat in\n        // front of it in the stack.\n        zIndex: index === still ? 2 : 1,\n        width: cardW,\n        transform:\n          still === null ? undefined : `translateX(${-still * step}px)`,\n      }}\n    >\n      <motion.div style={{ scale: still === null ? scale : resting }}>\n        {children}\n      </motion.div>\n    </div>\n  );\n}\n\n// Swaps a card's numbers with a soft blur as you move between cards.\nfunction Swap({ id, children }: { id: string; children: React.ReactNode }) {\n  return (\n    <AnimatePresence initial={false} mode=\"popLayout\">\n      <motion.div\n        key={id}\n        initial={{ opacity: 0, filter: \"blur(4px)\" }}\n        animate={{\n          opacity: 1,\n          filter: \"blur(0px)\",\n          transition: { duration: 0.25 },\n        }}\n        exit={{\n          opacity: 0,\n          filter: \"blur(4px)\",\n          transition: { duration: 0.15 },\n        }}\n      >\n        {children}\n      </motion.div>\n    </AnimatePresence>\n  );\n}\n\n// The cards are objects with their own materials, the same in both themes.\nfunction CardFace({ id, details }: { id: CardId; details?: boolean }) {\n  const [shown, setShown] = useState(false);\n  const card = CARDS.find((c) => c.id === id)!;\n  const dark = id === \"virtual\";\n  // On a narrow phone the card's name would run into its button, so it\n  // keeps just the last four digits.\n  const narrow = use(Geo).cardW < 270;\n  return (\n    <div\n      className={cn(\n        \"relative size-full overflow-hidden rounded-[18px] shadow-[0_1px_1px_rgb(0_0_0/0.12),0_10px_24px_-10px_rgb(0_0_0/0.45)]\",\n        dark ? \"text-white\" : \"text-[#2a2a2c]\",\n      )}\n      style={{\n        background: dark\n          ? \"radial-gradient(120% 90% at 85% 0%, #2c2c30, transparent 60%), linear-gradient(140deg, #1d1d20, #0a0a0b)\"\n          : \"linear-gradient(125deg, #f2f2f4 0%, #c7c8cc 38%, #e6e7ea 62%, #a9aaae 100%)\",\n      }}\n    >\n      {dark ? <Linework /> : <Brushed />}\n      {dark ? (\n        <span className=\"absolute top-3 left-3 rounded-full border border-white/35 px-2 py-0.5 text-[11px] font-medium text-white/90\">\n          Virtual\n        </span>\n      ) : (\n        <Chip />\n      )}\n      <AnimatePresence initial={false}>\n        {shown && (\n          <motion.span\n            className=\"absolute inset-x-0 top-[44%] text-center font-mono text-[17px] tracking-[0.08em] tabular-nums\"\n            initial={{ opacity: 0, filter: \"blur(6px)\", y: 4 }}\n            animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n            exit={{\n              opacity: 0,\n              filter: \"blur(6px)\",\n              transition: { duration: 0.15 },\n            }}\n            transition={{ duration: 0.3, ease: EASE_OUT }}\n          >\n            {card.number}\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <span\n        className={cn(\n          \"absolute bottom-3.5 left-3.5 text-[12px] font-medium\",\n          dark ? \"text-white/85\" : \"text-black/60\",\n        )}\n      >\n        {narrow ? `•${card.last4}` : `${card.name} •${card.last4}`}\n      </span>\n      {details ? (\n        <button\n          type=\"button\"\n          onClick={() => setShown((s) => !s)}\n          className={cn(\n            \"absolute right-3 bottom-2.5 flex h-7 items-center gap-1 rounded-full px-2.5 text-[12px] font-medium outline-hidden transition-[background-color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-current active:scale-[0.96]\",\n            dark\n              ? \"bg-white/12 hover:bg-white/20\"\n              : \"bg-black/8 hover:bg-black/12\",\n          )}\n        >\n          <Glyph\n            d=\"M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z M8 10a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z\"\n            className=\"size-3.5\"\n          />\n          {shown ? \"Hide details\" : \"View details\"}\n        </button>\n      ) : null}\n    </div>\n  );\n}\n\n// Fine white rules and a dashed orbit, like a print on dark plastic.\nfunction Linework() {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 296 185\"\n      className=\"absolute inset-0 size-full\"\n      fill=\"none\"\n      stroke=\"white\"\n    >\n      <g strokeOpacity={0.32} strokeWidth={0.8}>\n        <path d=\"M150 -10 L262 195\" />\n        <path d=\"M176 -10 L288 195\" />\n        <path d=\"M206 -10 L300 160\" />\n        <path d=\"M120 -10 L180 195\" />\n      </g>\n      <circle cx=\"222\" cy=\"78\" r=\"11\" strokeOpacity={0.5} strokeWidth={1} />\n      <path\n        d=\"M40 200 C110 120 220 70 320 64\"\n        strokeOpacity={0.4}\n        strokeWidth={1}\n        strokeDasharray=\"4 5\"\n      />\n    </svg>\n  );\n}\n\n// Brushed metal grain with the lab's mark pressed into it.\nfunction Brushed() {\n  return (\n    <>\n      <span\n        aria-hidden\n        className=\"absolute inset-0\"\n        style={{\n          backgroundImage:\n            \"repeating-linear-gradient(90deg, rgb(255 255 255 / 0.07) 0 1px, transparent 1px 3px), repeating-linear-gradient(90deg, rgb(0 0 0 / 0.03) 0 1px, transparent 1px 5px)\",\n        }}\n      />\n      {/* The lab's mark, small and pressed into the metal: shadow along\n          the top of each dot, light along the bottom. */}\n      <span\n        aria-hidden\n        className=\"absolute top-4 right-4 grid grid-cols-3 gap-[5px]\"\n      >\n        {Array.from({ length: 9 }, (_, i) => (\n          <span\n            key={i}\n            className=\"size-[7px] rounded-full\"\n            style={{\n              background: \"linear-gradient(160deg, #a9aaae, #d4d5d8)\",\n              boxShadow:\n                \"inset 0 1px 1px rgb(0 0 0 / 0.25), 0 0.5px 0 rgb(255 255 255 / 0.8)\",\n            }}\n          />\n        ))}\n      </span>\n    </>\n  );\n}\n\nfunction Chip() {\n  return (\n    <span\n      aria-hidden\n      className=\"absolute top-[46px] left-5 block h-[30px] w-[40px] overflow-hidden rounded-[6px]\"\n      style={{\n        background: \"linear-gradient(135deg, #e3e3e6, #a8a9ae 55%, #d6d7da)\",\n        boxShadow:\n          \"inset 0 0 0 1px rgb(0 0 0 / 0.18), 0 1px 0 rgb(255 255 255 / 0.6)\",\n      }}\n    >\n      <svg\n        viewBox=\"0 0 40 30\"\n        className=\"size-full\"\n        fill=\"none\"\n        stroke=\"rgb(0 0 0 / 0.28)\"\n        strokeWidth={0.8}\n      >\n        <path d=\"M0 10h13M0 20h13M27 10h13M27 20h13M13 0v30M27 0v30M13 15h14\" />\n      </svg>\n    </span>\n  );\n}\n\nfunction MiniMap() {\n  return (\n    <svg\n      aria-hidden\n      viewBox=\"0 0 150 80\"\n      className=\"absolute inset-x-0 bottom-0 h-[62px] w-full\"\n      fill=\"none\"\n    >\n      <g className=\"stroke-border\" strokeWidth={1.5}>\n        <path d=\"M-5 60 C30 50 50 70 80 52 S130 30 160 40\" />\n        <path d=\"M20 85 L60 10\" />\n        <path d=\"M95 85 L120 0\" />\n        <path d=\"M-5 30 L160 22\" />\n      </g>\n      <circle cx=\"104\" cy=\"36\" r=\"7\" className=\"fill-sky-500\" />\n      <circle cx=\"104\" cy=\"36\" r=\"2.5\" fill=\"white\" />\n    </svg>\n  );\n}\n\nfunction Row({\n  label,\n  value,\n  tone,\n  avatar,\n}: {\n  label: string;\n  value: string;\n  tone?: string;\n  avatar?: string;\n}) {\n  return (\n    <p className=\"mt-1.5 flex items-center justify-between gap-2 text-[12px]\">\n      <span className=\"flex min-w-0 items-center gap-1.5 text-muted-foreground\">\n        {avatar && (\n          <span\n            className=\"size-3.5 shrink-0 rounded-full\"\n            style={{ background: avatar }}\n          />\n        )}\n        <span className=\"truncate\">{label}</span>\n      </span>\n      <span\n        className={cn(\"font-medium tabular-nums\", tone ?? \"text-foreground\")}\n      >\n        {value}\n      </span>\n    </p>\n  );\n}\n\nfunction Account({\n  name,\n  apy,\n  amount,\n  tint,\n}: {\n  name: string;\n  apy: string;\n  amount: string;\n  tint: string;\n}) {\n  return (\n    <div className=\"rounded-[18px] bg-muted p-3.5\">\n      <p className=\"flex items-center gap-2 text-[14px] font-medium\">\n        <span className=\"size-5 rounded-full\" style={{ background: tint }} />\n        {name}\n      </p>\n      <p className=\"mt-3 text-[11px] font-medium text-green-600 dark:text-green-400\">\n        {apy}\n      </p>\n      <p className=\"text-[19px] font-semibold tracking-tight tabular-nums\">\n        {amount}\n      </p>\n    </div>\n  );\n}\n\nfunction Header({\n  view,\n  onClose,\n}: {\n  view: \"home\" | \"cards\";\n  onClose: () => void;\n}) {\n  return (\n    <div\n      className=\"relative z-10 flex h-12 items-center px-4\"\n      style={{ marginTop: 44 }}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {view === \"home\" ? (\n          <motion.span\n            key=\"back\"\n            aria-hidden\n            className=\"grid size-9 place-items-center\"\n            initial={{ opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, scale: 0.6 }}\n          >\n            <Glyph d=\"M10 3 5 8l5 5\" className=\"size-[18px]\" />\n          </motion.span>\n        ) : (\n          <motion.button\n            key=\"close\"\n            type=\"button\"\n            onClick={onClose}\n            aria-label=\"Close cards\"\n            className=\"grid size-9 place-items-center rounded-full outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\"\n            initial={{ opacity: 0, scale: 0.6, rotate: -45 }}\n            animate={{ opacity: 1, scale: 1, rotate: 0 }}\n            exit={{ opacity: 0, scale: 0.6 }}\n          >\n            <Glyph d=\"m4 4 8 8M12 4l-8 8\" className=\"size-[18px]\" />\n          </motion.button>\n        )}\n      </AnimatePresence>\n      {/* Centred over the whole bar, so it can't take the close button's\n          clicks. */}\n      <span className=\"pointer-events-none absolute inset-x-0 grid place-items-center\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={view}\n            className=\"text-[16px] font-semibold\"\n            initial={{ opacity: 0, y: 8, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={{ opacity: 0, y: -8, filter: \"blur(4px)\" }}\n            transition={{ duration: 0.25, ease: EASE_OUT }}\n          >\n            {view === \"home\" ? \"Money\" : \"Cards\"}\n          </motion.span>\n        </AnimatePresence>\n      </span>\n      <span className=\"ml-auto flex gap-1 text-foreground\">\n        <Glyph\n          d=\"M8 14.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13Z M6.3 6.2a1.8 1.8 0 1 1 2.4 1.7c-.4.2-.7.5-.7 1v.3 M8 11.3v.1\"\n          className=\"size-[18px]\"\n        />\n      </span>\n    </div>\n  );\n}\n\nfunction StatusBar() {\n  return (\n    <div\n      aria-hidden\n      className=\"absolute inset-x-0 top-0 z-10 flex h-11 items-center justify-between px-8 pt-1 text-[14px] font-semibold\"\n    >\n      <span className=\"tabular-nums\">9:41</span>\n      <span className=\"absolute top-2.5 left-1/2 h-[26px] w-[96px] -translate-x-1/2 rounded-full bg-black\" />\n      <span className=\"flex items-center gap-1.5\">\n        <svg viewBox=\"0 0 18 12\" className=\"h-[11px]\" fill=\"currentColor\">\n          <rect x=\"0\" y=\"8\" width=\"3\" height=\"4\" rx=\"1\" />\n          <rect x=\"5\" y=\"5.5\" width=\"3\" height=\"6.5\" rx=\"1\" />\n          <rect x=\"10\" y=\"3\" width=\"3\" height=\"9\" rx=\"1\" />\n          <rect x=\"15\" y=\"0\" width=\"3\" height=\"12\" rx=\"1\" />\n        </svg>\n        <svg\n          viewBox=\"0 0 26 12\"\n          className=\"h-[11px]\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n        >\n          <rect\n            x=\"0.5\"\n            y=\"0.5\"\n            width=\"22\"\n            height=\"11\"\n            rx=\"3.5\"\n            strokeOpacity={0.4}\n          />\n          <rect\n            x=\"2.5\"\n            y=\"2.5\"\n            width=\"16\"\n            height=\"7\"\n            rx=\"2\"\n            fill=\"currentColor\"\n            stroke=\"none\"\n          />\n          <path\n            d=\"M24.5 4v4\"\n            strokeOpacity={0.4}\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n          />\n        </svg>\n      </span>\n    </div>\n  );\n}\n\nfunction TabBar() {\n  return (\n    <div\n      aria-hidden\n      className=\"absolute inset-x-0 bottom-0 z-20 flex h-[64px] items-start justify-around border-t border-border bg-background/90 px-4 pt-3 text-muted-foreground backdrop-blur-md\"\n    >\n      {TABS.map((d, i) => (\n        <Glyph\n          key={i}\n          d={d}\n          className={cn(\"size-[22px]\", i === 0 && \"text-foreground\")}\n        />\n      ))}\n      <span className=\"absolute bottom-2 left-1/2 h-[5px] w-[120px] -translate-x-1/2 rounded-full bg-foreground\" />\n    </div>\n  );\n}\n\nfunction Glyph({ d, className }: { d: string; className?: string }) {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      className={cn(\"size-4\", className)}\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.5}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden\n    >\n      <path d={d} />\n    </svg>\n  );\n}\n\nconst ACTIONS = [\n  { label: \"Deposit\", d: \"M8 2v8M4.5 6.5 8 10l3.5-3.5M2.5 13.5h11\" },\n  { label: \"Send\", d: \"M14 2 7 9M14 2l-4.5 12-2.5-5-5-2.5L14 2Z\" },\n  {\n    label: \"Request\",\n    d: \"M2.5 2.5h4v4h-4zM9.5 2.5h4v4h-4zM2.5 9.5h4v4h-4zM9.5 9.5h4v4h-4z\",\n  },\n  {\n    label: \"Transfer\",\n    d: \"M5 2.5v11M2.5 5 5 2.5 7.5 5M11 13.5v-11M8.5 11l2.5 2.5 2.5-2.5\",\n  },\n];\n\nconst TABS = [\n  \"M2.5 7 8 2.5 13.5 7v6.5h-4v-4h-3v4h-4z\",\n  \"M7 12.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11ZM11 11l3.5 3.5\",\n  \"M8 14.5a6.5 6.5 0 1 0 0-13 6.5 6.5 0 0 0 0 13ZM8 4.5v3.5l2.5 1.5\",\n  \"M4 6.5a4 4 0 1 1 8 0c0 4 1.5 5 1.5 5h-11S4 10.5 4 6.5ZM6.5 13.5a1.5 1.5 0 0 0 3 0\",\n  \"M2.5 4.5h11v7h-6l-3 2.5v-2.5h-2z\",\n];\n\nexport default function WalletCardsDemo() {\n  return <WalletCards />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["cards"],"docs":"From ui lab: https://lab.xevrion.dev/lab/wallet-cards"}