{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"payment-card","type":"registry:component","title":"Payment card","description":"Floods with the card network's colours from the corner the moment the first digits give it away.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion","simple-icons"],"registryDependencies":["utils"],"files":[{"path":"components/payment-card.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport {\n  siAmericanexpress,\n  siDiscover,\n  siMastercard,\n  siStripe,\n  siVisa,\n} from \"simple-icons\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After Adam Whitcroft's animated payment card (https://x.com/adamwhitcroft):\n// a plain card field that floods with the network's colours, from the\n// corner, the moment the first digits give the network away.\n\ntype Brand = \"stripe\" | \"visa\" | \"mastercard\" | \"amex\" | \"discover\";\n\ntype BrandSpec = {\n  name: string;\n  icon: { path: string };\n  test: RegExp;\n  length: number;\n  gaps: number[];\n  cvc: number;\n  // The card's colours are brand data, not theme: each one is the network's\n  // own hue, deepened so white text sits on it.\n  paint: string;\n  // The warm edge the flood carries in.\n  rim: string;\n  glow: string;\n};\n\nconst BRANDS: Record<Brand, BrandSpec> = {\n  // Stripe's test number: everyone who has built a checkout has typed it.\n  stripe: {\n    name: \"Stripe test card\",\n    icon: siStripe,\n    test: /^4242/,\n    length: 16,\n    gaps: [4, 8, 12],\n    cvc: 3,\n    paint:\n      \"radial-gradient(120% 90% at 92% 0%, rgb(140 128 255 / 0.95), transparent 60%), linear-gradient(160deg, #2c1d86, #120a3f)\",\n    rim: \"rgb(196 186 255)\",\n    glow: \"rgb(99 91 255 / 0.45)\",\n  },\n  amex: {\n    name: \"American Express\",\n    icon: siAmericanexpress,\n    test: /^3[47]/,\n    length: 15,\n    gaps: [4, 10],\n    cvc: 4,\n    paint:\n      \"radial-gradient(120% 90% at 92% 0%, rgb(86 178 255 / 0.9), transparent 60%), linear-gradient(160deg, #0e3d6c, #061a31)\",\n    rim: \"rgb(170 220 255)\",\n    glow: \"rgb(46 119 188 / 0.45)\",\n  },\n  mastercard: {\n    name: \"Mastercard\",\n    icon: siMastercard,\n    // The 2-series is only 2221 to 2720; 2200 to 2204 belongs to Mir.\n    test: /^(5[1-5]|222[1-9]|22[3-9]\\d|2[3-6]\\d{2}|27[01]\\d|2720)/,\n    length: 16,\n    gaps: [4, 8, 12],\n    cvc: 3,\n    paint:\n      \"radial-gradient(90% 80% at 78% 8%, rgb(255 62 46 / 0.85), transparent 62%), radial-gradient(70% 60% at 10% 110%, rgb(255 150 40 / 0.3), transparent 70%), linear-gradient(160deg, #2a0807, #0d0303)\",\n    rim: \"rgb(255 190 120)\",\n    glow: \"rgb(235 0 27 / 0.35)\",\n  },\n  discover: {\n    name: \"Discover\",\n    icon: siDiscover,\n    test: /^(6011|65|64[4-9])/,\n    length: 16,\n    gaps: [4, 8, 12],\n    cvc: 3,\n    paint:\n      \"radial-gradient(110% 90% at 92% 0%, rgb(255 128 40 / 0.9), transparent 60%), linear-gradient(160deg, #3a1a06, #160902)\",\n    rim: \"rgb(255 210 150)\",\n    glow: \"rgb(255 96 0 / 0.35)\",\n  },\n  visa: {\n    name: \"Visa\",\n    icon: siVisa,\n    test: /^4/,\n    length: 16,\n    gaps: [4, 8, 12],\n    cvc: 3,\n    paint:\n      \"radial-gradient(120% 90% at 92% 0%, rgb(64 96 255 / 0.9), transparent 60%), linear-gradient(160deg, #141b5c, #070a26)\",\n    rim: \"rgb(255 180 110)\",\n    glow: \"rgb(26 31 113 / 0.5)\",\n  },\n};\n// Stripe's test number starts with a 4, so it is checked before Visa.\nconst ORDER: Brand[] = [\"stripe\", \"amex\", \"mastercard\", \"discover\", \"visa\"];\n\nconst detect = (digits: string) =>\n  ORDER.find((b) => BRANDS[b].test.test(digits)) ?? null;\n\nfunction format(digits: string, brand: Brand | null) {\n  const gaps = brand ? BRANDS[brand].gaps : [4, 8, 12];\n  let out = \"\";\n  [...digits].forEach((d, i) => {\n    if (gaps.includes(i)) out += \" \";\n    out += d;\n  });\n  return out;\n}\n\n// Long enough to be seen sweeping across rather than snapping on. An even\n// curve: a front-loaded one covers most of the card in the first frames.\nconst FLOOD = { duration: 0.6, ease: [0.45, 0, 0.2, 1] } as const;\n// Backing out is quicker: the card is just going back to plain.\nconst RETREAT = { duration: 0.35, ease: [0.77, 0, 0.175, 1] } as const;\n// When the flood reaches each line (ms), bottom-left first, so the text\n// turns white as the colour arrives under it. Going back, the colour leaves\n// the top right first, so the order reverses.\nconst REACH = {\n  footer: [130, 190],\n  number: [170, 170],\n  name: [190, 150],\n  title: [260, 110],\n} as const;\n// The far corner is the last place the flood reaches.\nconst LOGO_IN = 0.45;\n\nconst ITEMS = [\n  [\"Oat flat white\", 4.8],\n  [\"Almond croissant\", 4.2],\n  [\"Sourdough loaf\", 9.5],\n] as const;\nconst SUBTOTAL = ITEMS.reduce((sum, [, price]) => sum + price, 0);\nconst TAX = Math.round(SUBTOTAL * 0.08 * 100) / 100;\nconst TOTAL = SUBTOTAL + TAX;\n\n// Random digits rarely match a network, so the flood would go unseen. These\n// are the networks' published test numbers, typed in for you on a tap.\nconst SAMPLES = [\n  { label: \"Visa\", number: \"4111111111111111\" },\n  { label: \"Mastercard\", number: \"5555555555554444\" },\n  { label: \"Amex\", number: \"378282246310005\" },\n  // Starts as Visa and floods to Stripe at the fourth digit.\n  { label: \"Stripe test\", number: \"4242424242424242\" },\n] as const;\n// Quick enough not to keep anyone waiting, slow enough to read as typing.\nconst TYPE_EVERY = 55;\n\nexport function PaymentCard({\n  cardholder = \"Yash B\",\n  samples = true,\n  className,\n}: {\n  cardholder?: string;\n  // The row of test cards under the button.\n  samples?: boolean;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const [number, setNumber] = useState(\"\");\n  const [expiry, setExpiry] = useState(\"\");\n  const [cvc, setCvc] = useState(\"\");\n  const [paid, setPaid] = useState(false);\n  // The brand underneath stays painted while the new one floods over it.\n  const [layers, setLayers] = useState<{\n    under: Brand | null;\n    over: Brand | null;\n  }>({ under: null, over: null });\n  const shownBrand = useRef<Brand | null>(null);\n\n  const digits = number.replace(/\\D/g, \"\");\n  const brand = detect(digits);\n  const spec = brand ? BRANDS[brand] : null;\n  const flooded = brand !== null;\n\n  const cardRef = useRef<HTMLDivElement>(null);\n  const numberInput = useRef<HTMLInputElement>(null);\n  const caret = useRef<number | null>(null);\n\n  // 0 is the flood folded into the bottom-left corner, 1 is the whole card.\n  const progress = useMotionValue(0);\n  const radius = useTransform(progress, (p) => {\n    const card = cardRef.current;\n    const reach = card ? Math.hypot(card.offsetWidth, card.offsetHeight) : 400;\n    // A little past the far corner, so the soft rim clears it too.\n    return p * (reach + 40);\n  });\n  const clipPath = useMotionTemplate`circle(${radius}px at 0% 100%)`;\n  const rim = useMotionTemplate`radial-gradient(circle at 0% 100%, transparent calc(${radius}px - 28px), var(--rim) calc(${radius}px - 6px), transparent ${radius}px)`;\n  // The rim only exists while the edge is moving.\n  const rimOpacity = useTransform(progress, [0, 0.05, 0.8, 1], [0, 1, 1, 0]);\n\n  // The brand whose colour is in the clipped layer right now, including one\n  // that is still pulling back.\n  const overBrand = useRef<Brand | null>(null);\n  const flow = useRef<ReturnType<typeof animate>>(undefined);\n\n  const repaint = (next: Brand | null) => {\n    const previous = shownBrand.current;\n    if (next === previous) return;\n    shownBrand.current = next;\n    flow.current?.stop();\n    const run = (to: 0 | 1, how: typeof FLOOD | typeof RETREAT) => {\n      if (reduceMotion) progress.jump(to);\n      else flow.current = animate(progress, to, how);\n    };\n    if (!next) {\n      setLayers((l) => ({ under: null, over: l.over }));\n      run(0, RETREAT);\n      return;\n    }\n    if (!previous && overBrand.current === next && progress.get() > 0) {\n      // Typed again while the same colour was pulling back: it turns round\n      // from where it is instead of starting over.\n      run(1, FLOOD);\n      return;\n    }\n    if (!previous && progress.get() > 0) {\n      // A different network mid-retreat: carry on from the current edge so\n      // the card never blinks back to plain.\n      overBrand.current = next;\n      setLayers({ under: null, over: next });\n      run(1, FLOOD);\n      return;\n    }\n    overBrand.current = next;\n    setLayers({ under: previous, over: next });\n    progress.jump(0);\n    run(1, FLOOD);\n  };\n\n  const typeNumber = (raw: string) => {\n    const nextDigits = raw.replace(/\\D/g, \"\");\n    const nextBrand = detect(nextDigits);\n    const trimmed = nextDigits.slice(0, nextBrand ? BRANDS[nextBrand].length : 19);\n    setNumber(format(trimmed, nextBrand));\n    setPaid(false);\n    repaint(nextBrand);\n  };\n\n  // A tapped test card types itself in a digit at a time, so the flood\n  // happens the way it would for someone typing, then fills the rest so Pay\n  // wakes up too.\n  const [trying, setTrying] = useState<string | null>(null);\n  const typing = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const stopTyping = () => {\n    typing.current.forEach(clearTimeout);\n    typing.current = [];\n  };\n  useEffect(() => stopTyping, []);\n\n  const tryCard = (sample: (typeof SAMPLES)[number]) => {\n    stopTyping();\n    setTrying(sample.label);\n    // The first digit replaces whatever was there, so the new colour floods\n    // straight over the old one rather than the card going plain between.\n    setExpiry(\"\");\n    setCvc(\"\");\n    const step = reduceMotion ? 0 : TYPE_EVERY;\n    // Starts with two digits at once: one alone (\"3\") doesn't name a network\n    // yet, and the colour would start backing out before the second lands.\n    for (let i = 2; i <= sample.number.length; i++) {\n      typing.current.push(\n        setTimeout(() => typeNumber(sample.number.slice(0, i)), step * (i - 2)),\n      );\n    }\n    typing.current.push(\n      setTimeout(() => {\n        setExpiry(\"12 / 28\");\n        setCvc(detect(sample.number) === \"amex\" ? \"1234\" : \"123\");\n      }, step * (sample.number.length + 2)),\n    );\n  };\n\n  // Spaces are added as you type, so the caret is put back after the same\n  // count of digits rather than the same character index.\n  useLayoutEffect(() => {\n    const input = numberInput.current;\n    if (caret.current === null || !input) return;\n    let seen = 0;\n    let at = 0;\n    while (at < number.length && seen < caret.current) {\n      if (/\\d/.test(number[at])) seen += 1;\n      at += 1;\n    }\n    input.setSelectionRange(at, at);\n    caret.current = null;\n  }, [number]);\n\n  // The card's show: someone types a card in, then thinks better of it.\n  // Programmatic, so it never takes focus.\n  useEffect(() => {\n    if (!play) return;\n    const timers: ReturnType<typeof setTimeout>[] = [];\n    const script = [\"42424242\", \"55554444\"];\n    // Each round schedules from its own start: typed in, held, backed out.\n    const run = (round: number, start: number) => {\n      const target = script[round % script.length];\n      let at = start;\n      for (let i = 1; i <= target.length; i++) {\n        at += 110;\n        timers.push(setTimeout(() => typeNumber(target.slice(0, i)), at));\n      }\n      at += 1200;\n      for (let i = target.length - 1; i >= 0; i--) {\n        at += 45;\n        timers.push(setTimeout(() => typeNumber(target.slice(0, i)), at));\n      }\n      timers.push(setTimeout(() => run(round + 1, 0), at + 700));\n    };\n    run(0, 400);\n    return () => {\n      timers.forEach(clearTimeout);\n      typeNumber(\"\");\n    };\n    // typeNumber is recreated every render but only writes state.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [play]);\n\n  const complete =\n    spec !== null &&\n    digits.length === spec.length &&\n    expiry.replace(/\\D/g, \"\").length === 4 &&\n    cvc.length === spec.cvc;\n\n  useEffect(() => {\n    if (!paid) return;\n    const timer = setTimeout(() => setPaid(false), 1800);\n    return () => clearTimeout(timer);\n  }, [paid]);\n\n  const ink = ([arrive, leave]: readonly [number, number]) => ({\n    transitionDelay: reduceMotion ? \"0ms\" : `${flooded ? arrive : leave}ms`,\n  });\n  const inkClass = cn(\n    \"transition-[color] duration-200 ease-out motion-reduce:transition-none\",\n    flooded ? \"text-white\" : \"text-foreground\",\n  );\n  const fieldClass =\n    \"min-w-0 bg-transparent font-mono outline-hidden placeholder:text-current placeholder:opacity-45\";\n\n  return (\n    <div className={cn(\"flex w-[340px] max-w-full flex-col gap-2.5\", className)}>\n      {/* The receipt the card is paying for. */}\n      <div className=\"rounded-[18px] bg-background px-4 py-3.5 font-mono text-[12px] shadow-raised\">\n        {ITEMS.map(([item, price]) => (\n          <p key={item} className=\"flex justify-between text-muted-foreground\">\n            <span>{item}</span>\n            <span className=\"tabular-nums\">{price.toFixed(2)}</span>\n          </p>\n        ))}\n        <p className=\"mt-1 flex justify-between text-muted-foreground\">\n          <span>Tax</span>\n          <span className=\"tabular-nums\">{TAX.toFixed(2)}</span>\n        </p>\n        <p className=\"mt-2 flex justify-between border-t border-dashed border-border pt-2 font-medium\">\n          <span>Total</span>\n          <span className=\"tabular-nums\">${TOTAL.toFixed(2)}</span>\n        </p>\n      </div>\n\n      <div className=\"relative\">\n        {/* The network's colour as a soft light under the card: one light\n            per network, so switching fades one out and the next in rather\n            than dragging a red through purple to a blue. */}\n        {ORDER.map((b) => (\n          <div\n            key={b}\n            aria-hidden\n            className=\"absolute inset-x-6 top-6 -bottom-2 rounded-[24px] blur-2xl transition-[opacity] duration-500 ease-out\"\n            style={{\n              backgroundColor: BRANDS[b].glow,\n              opacity: brand === b ? 1 : 0,\n            }}\n          />\n        ))}\n        <div\n          ref={cardRef}\n          className=\"relative isolate h-[196px] overflow-hidden rounded-[18px] bg-background shadow-raised\"\n        >\n          {layers.under && (\n            <div\n              aria-hidden\n              className=\"absolute inset-0 -z-10\"\n              style={{ background: BRANDS[layers.under].paint }}\n            />\n          )}\n          {layers.over && (\n            <>\n              <motion.div\n                aria-hidden\n                className=\"absolute inset-0 -z-10\"\n                style={{ background: BRANDS[layers.over].paint, clipPath }}\n              />\n              {!reduceMotion && (\n                <motion.div\n                  aria-hidden\n                  className=\"pointer-events-none absolute inset-0 -z-10 blur-[5px]\"\n                  style={\n                    {\n                      \"--rim\": BRANDS[layers.over].rim,\n                      background: rim,\n                      opacity: rimOpacity,\n                    } as unknown as React.CSSProperties\n                  }\n                />\n              )}\n            </>\n          )}\n\n          <div className=\"flex h-full flex-col p-5\">\n            <div className=\"flex items-start justify-between\">\n              <span\n                className={cn(\"text-[14px] font-medium\", inkClass)}\n                style={ink(REACH.title)}\n              >\n                Payment\n              </span>\n              <span className=\"relative grid h-6 w-14 place-items-end\">\n                <AnimatePresence initial={false}>\n                  {spec && (\n                    <motion.svg\n                      key={brand}\n                      viewBox=\"0 0 24 24\"\n                      aria-hidden\n                      className=\"col-start-1 row-start-1 h-6 w-auto fill-white\"\n                      initial={\n                        reduceMotion\n                          ? { opacity: 0 }\n                          : { opacity: 0, scale: 0.8, filter: \"blur(4px)\" }\n                      }\n                      animate={{\n                        opacity: 1,\n                        scale: 1,\n                        filter: \"blur(0px)\",\n                        transition: {\n                          duration: 0.3,\n                          ease: [0.23, 1, 0.32, 1],\n                          delay: reduceMotion ? 0 : LOGO_IN,\n                        },\n                      }}\n                      exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                    >\n                      <path d={spec.icon.path} />\n                    </motion.svg>\n                  )}\n                </AnimatePresence>\n              </span>\n            </div>\n\n            <span\n              className={cn(\n                \"mt-auto font-mono text-[11px] tracking-[0.12em] uppercase opacity-70\",\n                inkClass,\n              )}\n              style={ink(REACH.name)}\n            >\n              {cardholder}\n            </span>\n            <input\n              ref={numberInput}\n              value={number}\n              onChange={(e) => {\n                const at = e.target.selectionStart ?? e.target.value.length;\n                caret.current = e.target.value.slice(0, at).replace(/\\D/g, \"\").length;\n                stopTyping();\n                setTrying(null);\n                typeNumber(e.target.value);\n              }}\n              inputMode=\"numeric\"\n              autoComplete=\"cc-number\"\n              aria-label=\"Card number\"\n              placeholder=\"0000 0000 0000 0000\"\n              className={cn(\n                fieldClass,\n                \"mt-1.5 text-[19px] tracking-[0.06em] tabular-nums\",\n                inkClass,\n                flooded ? \"caret-white\" : \"caret-foreground\",\n              )}\n              style={ink(REACH.number)}\n            />\n            <div\n              className={cn(\"mt-2.5 flex gap-4 text-[12px]\", inkClass)}\n              style={ink(REACH.footer)}\n            >\n              <input\n                value={expiry}\n                onChange={(e) => {\n                  const d = e.target.value.replace(/\\D/g, \"\").slice(0, 4);\n                  setExpiry(d.length > 2 ? `${d.slice(0, 2)} / ${d.slice(2)}` : d);\n                }}\n                inputMode=\"numeric\"\n                autoComplete=\"cc-exp\"\n                aria-label=\"Expiry date, month and year\"\n                placeholder=\"MM / YY\"\n                className={cn(fieldClass, \"w-[7ch]\")}\n              />\n              <input\n                value={cvc}\n                onChange={(e) =>\n                  setCvc(e.target.value.replace(/\\D/g, \"\").slice(0, spec?.cvc ?? 4))\n                }\n                inputMode=\"numeric\"\n                autoComplete=\"cc-csc\"\n                aria-label=\"Security code\"\n                placeholder=\"CVC\"\n                className={cn(fieldClass, \"w-[5ch]\")}\n              />\n            </div>\n          </div>\n        </div>\n      </div>\n\n      <button\n        type=\"button\"\n        disabled={!complete && !paid}\n        onClick={() => setPaid(true)}\n        className=\"relative h-11 rounded-full bg-foreground text-[14px] font-medium text-background outline-hidden transition-[opacity,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.97] disabled:opacity-40 disabled:active:scale-100\"\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={paid ? \"paid\" : \"pay\"}\n            className=\"inline-flex items-center gap-1.5\"\n            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, filter: \"blur(4px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, filter: \"blur(4px)\" }}\n            transition={{ duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n          >\n            {paid ? (\n              <>\n                <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.75} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n                  <path d=\"m3.5 8.5 3 3 6-7\" />\n                </svg>\n                Paid\n              </>\n            ) : (\n              <>Pay ${TOTAL.toFixed(2)}</>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </button>\n\n      {samples && play === null && (\n        <div className=\"flex flex-wrap items-center justify-center gap-1 pt-1 text-[12px]\">\n          <span className=\"mr-1 text-muted-foreground\">Try</span>\n          {SAMPLES.map((sample) => {\n            const on = trying === sample.label;\n            return (\n              <button\n                key={sample.label}\n                type=\"button\"\n                aria-pressed={on}\n                aria-label={`Fill in a sample ${sample.label} card`}\n                onClick={() => tryCard(sample)}\n                className={cn(\n                  \"h-7 touch-manipulation rounded-full px-2.5 font-medium outline-hidden transition-[background-color,color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-foreground active:scale-[0.96] motion-reduce:transition-[background-color,color]\",\n                  on\n                    ? \"bg-foreground text-background\"\n                    : \"bg-muted text-muted-foreground hover:bg-foreground/10 hover:text-foreground\",\n                )}\n              >\n                {sample.label}\n              </button>\n            );\n          })}\n        </div>\n      )}\n\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {paid ? \"Paid\" : spec ? spec.name : \"\"}\n      </p>\n    </div>\n  );\n}\n\nexport default function PaymentCardDemo() {\n  return <PaymentCard />;\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":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/payment-card"}