{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"coupon-code","type":"registry:component","title":"Coupon code","description":"Stamps your promo code onto the order and strikes out the price it beat.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/coupon-code.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, type ReactNode } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type LineItem = {\n  name: string;\n  detail: string;\n  price: number;\n  // The product's own color, shown as a swatch. It is data, not theme.\n  swatch?: string;\n  // A small product drawing laid over the swatch, in its color.\n  thumb?: ReactNode;\n};\n\nexport type PromoCode = {\n  code: string;\n  kind: \"percent\" | \"shipping\";\n  // Percent off the subtotal, for \"percent\" codes.\n  percent?: number;\n  // Shown under the field once applied.\n  blurb: string;\n  // A code that used to work gets a date, so the message can say when.\n  expired?: string;\n  // What to try instead, appended to the expired message.\n  instead?: string;\n};\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// The rubber stamp lands with a little thud: a quick overshoot sells the\n// weight of it without reading as a toy.\nconst STAMP = { type: \"spring\", visualDuration: 0.3, bounce: 0.35 } as const;\nconst LEAVE = { duration: 0.15, ease: EASE_OUT } as const;\n// Rows below the new one have to make room, which only height can do.\nconst ROW = { duration: 0.26, ease: EASE_OUT } as const;\n// A real lookup is a network trip. A short pending beat keeps the answer\n// from feeling canned, and is long enough to see the spinner.\nconst CHECK_MS = 450;\n\nfunction money(value: number, currency: string) {\n  return new Intl.NumberFormat(\"en-US\", { style: \"currency\", currency }).format(\n    value,\n  );\n}\n\n// One column of 0 to 9 per digit, keyed from the right so the ones stay\n// the ones when the number gets shorter. Moving the column is what makes the\n// value roll: down for a smaller digit, up for a bigger one.\nfunction Roll({ text }: { text: string }) {\n  const chars = [...text];\n  return (\n    <span aria-hidden className=\"inline-flex tabular-nums\">\n      {chars.map((char, i) => {\n        const place = chars.length - i;\n        if (!/\\d/.test(char)) {\n          return (\n            <span key={`s${place}`} className=\"inline-block\">\n              {char}\n            </span>\n          );\n        }\n        return (\n          <span\n            key={`d${place}`}\n            // Fades the neighbours peeking in above and below mid-roll; at\n            // rest the digit sits inside the clear middle.\n            className=\"relative inline-block h-[1lh] overflow-hidden [mask-image:linear-gradient(transparent,black_20%,black_80%,transparent)]\"\n          >\n            <span\n              // Values glide rather than snap, and the eye has to follow every\n              // column, so this runs longer than a UI transition.\n              className=\"flex flex-col transition-[translate] duration-[600ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n              style={{ translate: `0 ${-Number(char) * 10}%` }}\n            >\n              {Array.from({ length: 10 }, (_, n) => (\n                <span key={n} className=\"h-[1lh]\">\n                  {n}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\n// The old value stays readable under a line that draws across it, so you\n// can see exactly what the code changed.\nfunction Struck({\n  from,\n  to,\n  struck,\n}: {\n  from: string;\n  to: string;\n  struck: boolean;\n}) {\n  const reduceMotion = useReducedMotion();\n  return (\n    <span className=\"flex items-baseline justify-end gap-2\">\n      <motion.span\n        layout=\"position\"\n        transition={{ type: \"spring\", visualDuration: 0.26, bounce: 0 }}\n        className={cn(\n          \"relative tabular-nums transition-[color] duration-200 ease-out\",\n          struck ? \"text-muted-foreground\" : \"text-foreground\",\n        )}\n      >\n        {from}\n        <span\n          aria-hidden\n          className={cn(\n            \"absolute top-1/2 -right-0.5 -left-0.5 h-[1.5px] origin-left bg-current transition-[scale] ease-[cubic-bezier(0.77,0,0.175,1)] motion-reduce:transition-none\",\n            // Drawn after the stamp lands; erased first on the way back.\n            struck\n              ? \"scale-x-100 delay-150 duration-300\"\n              : \"scale-x-0 duration-200\",\n          )}\n        />\n      </motion.span>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {struck && (\n          <motion.span\n            key=\"to\"\n            initial={\n              reduceMotion\n                ? { opacity: 0 }\n                : { opacity: 0, x: 4, filter: \"blur(4px)\" }\n            }\n            animate={{\n              opacity: 1,\n              x: 0,\n              filter: \"blur(0px)\",\n              transition: { duration: 0.25, ease: EASE_OUT, delay: 0.3 },\n            }}\n            exit={{ opacity: 0, filter: \"blur(2px)\", transition: LEAVE }}\n            className=\"font-medium tabular-nums text-foreground\"\n          >\n            {to}\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction Spinner() {\n  return (\n    <svg\n      viewBox=\"0 0 16 16\"\n      aria-hidden\n      className=\"size-4 animate-spin motion-reduce:animate-none\"\n      fill=\"none\"\n    >\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"6\"\n        stroke=\"currentColor\"\n        strokeOpacity=\"0.25\"\n        strokeWidth=\"2\"\n      />\n      <path\n        d=\"M14 8a6 6 0 0 0-6-6\"\n        stroke=\"currentColor\"\n        strokeWidth=\"2\"\n        strokeLinecap=\"round\"\n      />\n    </svg>\n  );\n}\n\nexport function CouponCode({\n  items,\n  shipping,\n  codes,\n  currency = \"USD\",\n  onApply,\n  className,\n}: {\n  items: LineItem[];\n  shipping: number;\n  codes: PromoCode[];\n  currency?: string;\n  onApply?: (code: PromoCode | null) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const [draft, setDraft] = useState(\"\");\n  const [applied, setApplied] = useState<PromoCode | null>(null);\n  const [checking, setChecking] = useState(false);\n  const [error, setError] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n  const fieldRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const removeRef = useRef<HTMLButtonElement>(null);\n  const timer = useRef(0);\n  const shake = useRef<Animation | null>(null);\n  const cardRef = useRef<HTMLElement>(null);\n  // Focus follows the swap between field and tag, once the new one exists.\n  const focusNext = useRef<\"input\" | \"remove\" | null>(null);\n\n  useEffect(() => {\n    return () => {\n      window.clearTimeout(timer.current);\n      shake.current?.cancel();\n    };\n  }, []);\n\n  useEffect(() => {\n    if (focusNext.current === \"remove\") removeRef.current?.focus();\n    if (focusNext.current === \"input\") inputRef.current?.focus();\n    focusNext.current = null;\n  }, [applied]);\n\n  const subtotal = items.reduce((sum, item) => sum + item.price, 0);\n  const discountFor = (code: PromoCode) =>\n    code.kind === \"percent\"\n      ? Math.round(subtotal * (code.percent ?? 0)) / 100\n      : shipping;\n  const saved = applied ? discountFor(applied) : 0;\n  const total = subtotal + shipping - saved;\n  const shown = (value: number) => money(value, currency);\n\n  const reject = (message: string) => {\n    setError(message);\n    setAnnouncement(message);\n    const el = fieldRef.current;\n    if (!el) return;\n    shake.current?.cancel();\n    // A decaying 5px shake says \"no\" without the field lurching away.\n    shake.current = reduceMotion\n      ? null\n      : el.animate(\n          { translate: [\"0\", \"-5px\", \"5px\", \"-3px\", \"2px\", \"0\"] },\n          { duration: 320, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\" },\n        );\n  };\n\n  const apply = () => {\n    if (checking) return;\n    const code = draft.trim().toUpperCase();\n    if (!code) {\n      reject(\"Enter a code first.\");\n      inputRef.current?.focus();\n      return;\n    }\n    setChecking(true);\n    setError(\"\");\n    timer.current = window.setTimeout(() => {\n      setChecking(false);\n      const match = codes.find((c) => c.code === code);\n      if (!match) {\n        reject(\n          `There is no code called ${code}. Check the email it came in for the exact spelling.`,\n        );\n        return;\n      }\n      if (match.expired) {\n        reject(\n          `${match.code} expired on ${match.expired}.${match.instead ? ` ${match.instead}` : \"\"}`,\n        );\n        return;\n      }\n      focusNext.current = \"remove\";\n      setApplied(match);\n      // The thud: as the stamp lands (the spring reaches its low point near\n      // 110ms) the whole card gives by a pixel and a half, then recovers.\n      if (!reduceMotion) {\n        shake.current?.cancel();\n        shake.current =\n          cardRef.current?.animate(\n            { translate: [\"0 0\", \"0 1.5px\", \"0 0\"] },\n            {\n              duration: 200,\n              delay: 110,\n              easing: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n            },\n          ) ?? null;\n      }\n      onApply?.(match);\n      const off = discountFor(match);\n      setAnnouncement(\n        `${match.code} applied. You save ${shown(off)}. New total ${shown(subtotal + shipping - off)}.`,\n      );\n    }, CHECK_MS);\n  };\n\n  const remove = () => {\n    if (!applied) return;\n    focusNext.current = \"input\";\n    setAnnouncement(\n      `${applied.code} removed. Total ${shown(subtotal + shipping)}.`,\n    );\n    setApplied(null);\n    setDraft(\"\");\n    onApply?.(null);\n  };\n\n  return (\n    <section\n      ref={cardRef}\n      aria-labelledby={`${id}-title`}\n      className={cn(\n        \"w-[min(420px,100%)] rounded-[20px] bg-background p-5 shadow-raised\",\n        className,\n      )}\n    >\n      <div className=\"flex items-baseline justify-between\">\n        <h2\n          id={`${id}-title`}\n          className=\"text-[15px] font-semibold text-foreground\"\n        >\n          Order summary\n        </h2>\n        <span className=\"text-[13px] text-muted-foreground\">\n          {items.length} {items.length === 1 ? \"item\" : \"items\"}\n        </span>\n      </div>\n\n      <ul className=\"mt-4 flex flex-col gap-3\">\n        {items.map((item) => (\n          <li key={item.name} className=\"flex items-center gap-3\">\n            <span\n              aria-hidden\n              // The same faint outline images get, so a pale swatch still\n              // has an edge on a white card.\n              className={cn(\n                \"relative flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-[10px] shadow-[inset_0_0_0_1px_oklch(0_0_0/0.1)] dark:shadow-[inset_0_0_0_1px_oklch(1_0_0/0.1)]\",\n                item.thumb && \"bg-muted\",\n              )}\n              style={\n                item.thumb\n                  ? { color: item.swatch }\n                  : { background: item.swatch }\n              }\n            >\n              {item.thumb}\n            </span>\n            <span className=\"flex min-w-0 flex-1 flex-col\">\n              <span className=\"truncate text-sm font-medium text-foreground\">\n                {item.name}\n              </span>\n              <span className=\"truncate text-[13px] text-muted-foreground\">\n                {item.detail}\n              </span>\n            </span>\n            <span className=\"text-sm tabular-nums text-foreground\">\n              {shown(item.price)}\n            </span>\n          </li>\n        ))}\n      </ul>\n\n      <div className=\"mt-5 border-t border-border pt-4\">\n        <label\n          htmlFor={`${id}-code`}\n          className=\"text-[13px] font-medium text-foreground\"\n        >\n          Promo code\n        </label>\n        {/* Field and tag share one 40px slot, so nothing below them moves\n            when one becomes the other. */}\n        <div className=\"relative mt-2 h-10\">\n          <AnimatePresence initial={false}>\n            {applied ? (\n              <motion.div\n                key=\"tag\"\n                className=\"absolute inset-0 flex items-center justify-between pl-1\"\n                exit={{ opacity: 0, filter: \"blur(2px)\", transition: LEAVE }}\n              >\n                <motion.span\n                  initial={\n                    reduceMotion\n                      ? { opacity: 0 }\n                      : { opacity: 0, scale: 1.35, rotate: -9 }\n                  }\n                  animate={{ opacity: 1, scale: 1, rotate: -2 }}\n                  transition={STAMP}\n                  // A rubber stamp: a double rule in ink, the code in spaced\n                  // capitals, set down slightly crooked.\n                  className=\"flex h-8 items-center gap-1.5 rounded-md px-3 font-mono text-[13px] font-semibold tracking-[0.14em] text-foreground shadow-[inset_0_0_0_1.5px_var(--color-foreground),inset_0_0_0_3.5px_var(--color-background),inset_0_0_0_4.5px_var(--color-foreground)]\"\n                  // Ink never takes evenly: two offset grids of pinholes\n                  // knock small gaps out of the rules and letters.\n                  style={{\n                    maskImage:\n                      \"radial-gradient(circle at 30% 40%, transparent 0.7px, black 1.2px), radial-gradient(circle at 70% 60%, transparent 0.5px, black 1px)\",\n                    maskSize: \"9px 7px, 13px 11px\",\n                    maskComposite: \"intersect\",\n                  }}\n                >\n                  <svg\n                    viewBox=\"0 0 16 16\"\n                    aria-hidden\n                    className=\"size-3.5\"\n                    fill=\"none\"\n                    stroke=\"currentColor\"\n                    strokeWidth={2}\n                    strokeLinecap=\"round\"\n                    strokeLinejoin=\"round\"\n                  >\n                    <path d=\"m3.5 8.5 3 3 6-7\" />\n                  </svg>\n                  {applied.code}\n                </motion.span>\n                <button\n                  ref={removeRef}\n                  type=\"button\"\n                  onClick={remove}\n                  aria-label={`Remove code ${applied.code}`}\n                  className=\"h-9 rounded-lg px-3 text-[13px] text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n                >\n                  Remove\n                </button>\n              </motion.div>\n            ) : (\n              <motion.div\n                key=\"field\"\n                ref={fieldRef}\n                className=\"absolute inset-0 flex gap-2\"\n                initial={\n                  reduceMotion\n                    ? { opacity: 0 }\n                    : { opacity: 0, filter: \"blur(4px)\" }\n                }\n                animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, filter: \"blur(2px)\", transition: LEAVE }}\n                transition={{ duration: 0.2, ease: EASE_OUT }}\n              >\n                <input\n                  ref={inputRef}\n                  id={`${id}-code`}\n                  value={draft}\n                  onChange={(e) => {\n                    setDraft(e.target.value);\n                    if (error) setError(\"\");\n                  }}\n                  onKeyDown={(e) => {\n                    if (e.key === \"Enter\") {\n                      e.preventDefault();\n                      apply();\n                    }\n                  }}\n                  placeholder=\"Enter code\"\n                  autoComplete=\"off\"\n                  autoCapitalize=\"characters\"\n                  spellCheck={false}\n                  enterKeyHint=\"done\"\n                  aria-invalid={error ? true : undefined}\n                  aria-describedby={`${id}-note`}\n                  className={cn(\n                    \"h-10 min-w-0 flex-1 rounded-[10px] bg-muted px-3 font-mono text-sm tracking-[0.06em] text-foreground uppercase outline-hidden transition-[box-shadow] duration-150 ease-out placeholder:font-sans placeholder:tracking-normal placeholder:text-muted-foreground placeholder:normal-case focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n                    error && \"shadow-[inset_0_0_0_1px_var(--color-destructive)]\",\n                  )}\n                />\n                <button\n                  type=\"button\"\n                  onClick={apply}\n                  aria-busy={checking || undefined}\n                  className=\"relative flex h-10 w-[76px] shrink-0 items-center justify-center rounded-[10px] bg-foreground text-sm font-medium text-background outline-hidden transition-[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.96]\"\n                >\n                  <span\n                    className={cn(\n                      \"transition-[opacity,filter] duration-150 ease-out\",\n                      checking ? \"opacity-0 blur-[2px]\" : \"opacity-100\",\n                    )}\n                  >\n                    Apply\n                  </span>\n                  <span\n                    className={cn(\n                      \"absolute inset-0 flex items-center justify-center transition-[opacity] duration-150 ease-out\",\n                      checking ? \"opacity-100\" : \"opacity-0\",\n                    )}\n                  >\n                    <Spinner />\n                  </span>\n                  <span className=\"sr-only\">\n                    {checking ? \"Checking code\" : \"\"}\n                  </span>\n                </button>\n              </motion.div>\n            )}\n          </AnimatePresence>\n        </div>\n        {/* Two lines tall, so the longest message fits without the totals\n            below jumping when it appears. */}\n        <p\n          id={`${id}-note`}\n          className={cn(\n            \"mt-2 min-h-10 text-[13px] leading-5\",\n            error ? \"text-destructive\" : \"text-muted-foreground\",\n          )}\n        >\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            <motion.span\n              key={error || applied?.code || \"hint\"}\n              className=\"block\"\n              initial={\n                reduceMotion\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -2, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.1 } }}\n              transition={{ duration: 0.2, ease: EASE_OUT }}\n            >\n              {error || applied?.blurb || \"Codes are not case sensitive.\"}\n            </motion.span>\n          </AnimatePresence>\n        </p>\n      </div>\n\n      <dl className=\"mt-1 flex flex-col border-t border-border pt-3 text-sm\">\n        <div className=\"flex h-7 items-center justify-between\">\n          <dt className=\"text-muted-foreground\">Subtotal</dt>\n          <dd>\n            <Struck\n              from={shown(subtotal)}\n              to={applied ? shown(subtotal - saved) : shown(subtotal)}\n              struck={applied?.kind === \"percent\"}\n            />\n          </dd>\n        </div>\n        <div className=\"flex h-7 items-center justify-between\">\n          <dt className=\"text-muted-foreground\">Shipping</dt>\n          <dd>\n            <Struck\n              from={shown(shipping)}\n              to=\"Free\"\n              struck={applied?.kind === \"shipping\"}\n            />\n          </dd>\n        </div>\n        {/* Grows downward, below everything you might have clicked. */}\n        <AnimatePresence initial={false}>\n          {applied && (\n            <motion.div\n              key=\"saving\"\n              initial={{ height: 0, opacity: 0 }}\n              animate={{\n                height: 28,\n                opacity: 1,\n                transition: { ...ROW, delay: 0.1 },\n              }}\n              exit={{\n                height: 0,\n                opacity: 0,\n                transition: { duration: 0.2, ease: EASE_OUT },\n              }}\n              className=\"overflow-hidden\"\n            >\n              <motion.div\n                initial={reduceMotion ? false : { y: -6, filter: \"blur(4px)\" }}\n                animate={{ y: 0, filter: \"blur(0px)\" }}\n                transition={{ ...ROW, delay: 0.1 }}\n                className=\"flex h-7 items-center justify-between\"\n              >\n                <dt className=\"text-muted-foreground\">You save</dt>\n                <dd className=\"font-medium tabular-nums text-foreground\">\n                  {shown(saved)}\n                </dd>\n              </motion.div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n        <div className=\"mt-2 flex items-center justify-between border-t border-border pt-3\">\n          <dt className=\"text-[15px] font-semibold text-foreground\">Total</dt>\n          <dd className=\"text-lg font-semibold text-foreground\">\n            <Roll text={shown(total)} />\n            <span className=\"sr-only\">{shown(total)}</span>\n          </dd>\n        </div>\n      </dl>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announcement}\n      </span>\n    </section>\n  );\n}\n\n// Product drawings in the dye color (currentColor), with a faint darker\n// outline so a pale fabric still has an edge.\nconst SHIRT = (\n  <svg viewBox=\"0 0 48 48\" aria-hidden className=\"size-10\">\n    <path\n      d=\"M18 9 l-9 4 -5 10 6 3 3-5 v20 h22 v-20 l3 5 6-3 -5-10 -9-4 c-1 3-3.5 4.5-6 4.5 s-5-1.5-6-4.5 Z\"\n      fill=\"currentColor\"\n      stroke=\"oklch(0 0 0 / 0.18)\"\n      strokeWidth=\"1\"\n      strokeLinejoin=\"round\"\n    />\n    <path\n      d=\"M24 13.5 v23.5 M21 18 h6\"\n      stroke=\"oklch(0 0 0 / 0.15)\"\n      strokeWidth=\"1\"\n      strokeLinecap=\"round\"\n    />\n  </svg>\n);\nconst TOTE = (\n  <svg viewBox=\"0 0 48 48\" aria-hidden className=\"size-10\">\n    <path\n      d=\"M17 17 v-3 a7 7 0 0 1 14 0 v3\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth=\"2.5\"\n      strokeLinecap=\"round\"\n    />\n    <path\n      d=\"M11 17 h26 l-2 24 h-22 Z\"\n      fill=\"currentColor\"\n      stroke=\"oklch(0 0 0 / 0.2)\"\n      strokeWidth=\"1\"\n      strokeLinejoin=\"round\"\n    />\n    <path d=\"M12.5 22 h23\" stroke=\"oklch(1 0 0 / 0.18)\" strokeWidth=\"1\" />\n  </svg>\n);\n\nconst ITEMS: LineItem[] = [\n  // Swatches are the products' dye colors: data, the same in both themes.\n  {\n    name: \"Linen overshirt\",\n    detail: \"Sand, size M\",\n    price: 68,\n    swatch: \"#d9c9a8\",\n    thumb: SHIRT,\n  },\n  {\n    name: \"Waxed canvas tote\",\n    detail: \"Olive\",\n    price: 24,\n    swatch: \"#6b705c\",\n    thumb: TOTE,\n  },\n];\n\nconst CODES: PromoCode[] = [\n  {\n    code: \"WELCOME10\",\n    kind: \"percent\",\n    percent: 10,\n    blurb: \"10% off your first order.\",\n  },\n  { code: \"FREESHIP\", kind: \"shipping\", blurb: \"Shipping is on us.\" },\n  {\n    code: \"SUMMER20\",\n    kind: \"percent\",\n    percent: 20,\n    blurb: \"20% off summer styles.\",\n    expired: \"Aug 31\",\n    instead: \"WELCOME10 still takes 10% off.\",\n  },\n];\n\nexport default function CouponCodeDemo() {\n  return (\n    <div className=\"flex w-full flex-col items-center gap-3\">\n      <CouponCode items={ITEMS} shipping={6} codes={CODES} />\n      <p className=\"text-[13px] text-muted-foreground\">\n        Try <span className=\"font-mono text-foreground\">WELCOME10</span>,{\" \"}\n        <span className=\"font-mono text-foreground\">FREESHIP</span> or{\" \"}\n        <span className=\"font-mono text-foreground\">SUMMER20</span>\n      </p>\n    </div>\n  );\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/coupon-code"}