{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"receipt-printer","type":"registry:component","title":"Receipt printer","description":"Feeds a receipt out of the slot line by line; twist it against the cutter to tear it off.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/receipt-printer.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { flushSync } from \"react-dom\";\nimport {\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useTransform,\n  type AnimationPlaybackControls,\n  type MotionValue,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type ReceiptLine =\n  | { kind: \"title\"; text: string }\n  | { kind: \"center\"; text: string }\n  | { kind: \"row\"; left: string; right: string }\n  // Printed double height, the way a thermal head prints emphasis.\n  | { kind: \"total\"; left: string; right: string }\n  | { kind: \"rule\"; char?: string }\n  | { kind: \"barcode\"; code: string };\n\ntype Phase = \"idle\" | \"printing\" | \"printed\" | \"torn\";\ntype Piece = { key: number; lift: number; twist: number; vx: number; vy: number };\n\n// 80mm paper, scaled down.\nconst PAPER_WIDTH = 256;\nconst LINE = 18;\nconst HEIGHTS: Record<ReceiptLine[\"kind\"], number> = {\n  title: LINE,\n  center: LINE,\n  row: LINE,\n  rule: LINE,\n  total: LINE * 2,\n  barcode: 52,\n};\n// The serrated edge the cutter left on the roll last time.\nconst TOOTH = 5;\nconst TOOTH_WIDTH = 8;\n// Blank paper between that old tear and the first line, as a real roll has.\nconst LEAD = 14;\n// Between receipts the roll's serrated edge stands a little out of the\n// slot, as on a real printer, so the idle and torn states still show paper.\nconst STUB = TOOTH + 9;\n// After the last line the printer keeps feeding blank paper, so the text\n// clears the cutter before you tear.\nconst TAIL = 36;\n// A thermal head burns a whole line at once, then the stepper feeds it out\n// at about 360px/s. Slow enough to watch each line arrive, fast enough that\n// a full receipt takes under two seconds.\nconst FEED_SPEED = 360;\nconst BURN_MS = 34;\n// The paper leans back slightly as it rises out of the slot.\nconst LEAN = 9;\n// The slot grips the paper: a twist pivots it on the cutter and a pull only\n// gives a little, until either one rips it.\nconst MAX_TWIST = 9;\nconst GIVE = 10;\nconst TWIST_TEAR = 90;\nconst LIFT_TEAR = 70;\nconst FLICK = 700;\nconst SNAP_BACK = { type: \"spring\", stiffness: 700, damping: 34 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\n// Thermal paper is a physical material: bright white with near-black ink in\n// both themes, the way the avatars keep their light circle.\nconst PAPER = \"oklch(0.985 0.004 95)\";\nconst INK = \"oklch(0.26 0 0)\";\n// A hole into the printer reads dark in either theme.\nconst SLOT = \"oklch(0.16 0 0)\";\n// From the printer's top edge to the middle of the slot: the paper stands\n// on the printer and disappears exactly there.\nconst SLOT_MIDDLE = 13;\n\nfunction edge(height: number, tornBottom: boolean) {\n  const teeth = Math.ceil(PAPER_WIDTH / TOOTH_WIDTH);\n  const top: string[] = [];\n  const bottom: string[] = [];\n  for (let i = 0; i <= teeth; i++) {\n    const x = Math.min(i * TOOTH_WIDTH, PAPER_WIDTH);\n    const up = i % 2 === 1;\n    top.push(`${x}px ${up ? 0 : TOOTH}px`);\n    bottom.unshift(`${x}px ${tornBottom && !up ? height - TOOTH : height}px`);\n  }\n  return `polygon(${top.join(\", \")}, ${bottom.join(\", \")})`;\n}\n\nexport function ReceiptPrinter({\n  lines,\n  total,\n  autoPrint = true,\n  onTear,\n  className,\n}: {\n  lines: ReceiptLine[];\n  // Read out once printed, e.g. \"$23.65\".\n  total: string;\n  // Prints once when it first scrolls into view.\n  autoPrint?: boolean;\n  onTear?: () => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  // A small share: in a cropped preview most of it is clipped away, and it\n  // should still print.\n  const inView = useInView(rootRef, { once: true, amount: 0.15 });\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [pieces, setPieces] = useState<Piece[]>([]);\n  const pieceKey = useRef(0);\n  const running = useRef<AnimationPlaybackControls[]>([]);\n  const drag = useRef<{ id: number; x: number; y: number } | null>(null);\n\n  const height =\n    TOOTH + LEAD + lines.reduce((sum, l) => sum + HEIGHTS[l.kind], 0) + TAIL;\n\n  // How much paper has come out of the slot. Everything still inside the\n  // printer sits below the clip, so a line only appears once it's printed\n  // and fed out, never before.\n  const fed = useMotionValue(STUB);\n  const inside = useTransform(fed, (f) => height - f);\n  // None at the stub, which is shorter than the shade and would leave it\n  // hanging in the air above the slot; full once a receipt has risen.\n  const shade = useTransform(fed, [STUB, STUB + 24], [0, 1]);\n  const twist = useMotionValue(0);\n  const lift = useMotionValue(0);\n\n  const stop = () => {\n    running.current.forEach((a) => a.stop());\n    running.current = [];\n  };\n  useEffect(() => stop, []);\n\n  const print = () => {\n    stop();\n    drag.current = null;\n    twist.jump(0);\n    lift.jump(0);\n    fed.jump(STUB);\n    setPhase(\"printing\");\n    if (reduceMotion) {\n      fed.jump(height);\n      setPhase(\"printed\");\n      return;\n    }\n    // One keyframed run: feed out the blank lead, then for every line hold\n    // still while the head burns it and feed it out, then run out the tail.\n    // A single animation is simple to cancel when Reprint interrupts it.\n    const values = [STUB];\n    const times = [0];\n    let position = STUB;\n    let time = 0;\n    const feed = (px: number) => {\n      position += px;\n      time += (px / FEED_SPEED) * 1000;\n      values.push(position);\n      times.push(time);\n    };\n    const burn = () => {\n      time += BURN_MS;\n      values.push(position);\n      times.push(time);\n    };\n    feed(TOOTH + LEAD - STUB);\n    for (const line of lines) {\n      burn();\n      feed(HEIGHTS[line.kind]);\n    }\n    feed(TAIL);\n    const run = animate(fed, values, {\n      duration: time / 1000,\n      times: times.map((t) => t / time),\n      ease: \"linear\",\n    });\n    running.current = [run];\n    run.then(() => setPhase((p) => (p === \"printing\" ? \"printed\" : p)));\n  };\n\n  const printRef = useRef(print);\n  useEffect(() => {\n    printRef.current = print;\n  });\n  useEffect(() => {\n    if (autoPrint && inView) printRef.current();\n  }, [autoPrint, inView]);\n\n  const tear = (vx: number, vy: number) => {\n    stop();\n    drag.current = null;\n    const piece: Piece = {\n      key: pieceKey.current++,\n      lift: lift.get(),\n      twist: twist.get(),\n      vx,\n      vy,\n    };\n    // The torn piece leaves as its own copy, so Reprint works at once.\n    flushSync(() => {\n      setPhase(\"torn\");\n      if (!reduceMotion) setPieces((list) => [...list, piece]);\n    });\n    // What stays behind is the next stretch of roll, its fresh serrated\n    // edge standing just out of the slot.\n    fed.jump(STUB);\n    twist.jump(0);\n    lift.jump(0);\n    navigator.vibrate?.(10);\n    onTear?.();\n  };\n\n  const release = (e: React.PointerEvent) => {\n    if (drag.current?.id !== e.pointerId) return;\n    drag.current = null;\n    // Twist velocity is in degrees; about 10px of hand travel per degree.\n    const vx = twist.getVelocity() * 10;\n    const vy = lift.getVelocity();\n    if (Math.abs(vx) > FLICK || vy < -FLICK) return tear(vx, vy);\n    running.current = [\n      animate(twist, 0, SNAP_BACK),\n      animate(lift, 0, SNAP_BACK),\n    ];\n  };\n\n  const printed = phase === \"printed\";\n  const status = {\n    // The LED already says ready or busy, so the text says something else.\n    idle: \"Paper loaded\",\n    printing: \"Printing\",\n    printed: \"Twist or pull to tear it off\",\n    torn: \"Torn off\",\n  }[phase];\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\"flex w-[min(340px,100%)] flex-col items-center\", className)}\n    >\n      {/* The paper rises into this reserved space, so nothing else moves\n          while it prints. */}\n      <div\n        className=\"relative z-10 w-full\"\n        style={{ height: height + 8, marginBottom: -SLOT_MIDDLE }}\n      >\n        {/* Clipped only below the slot: paper still inside the printer is\n            hidden, while a twisted receipt can still swing past the sides. */}\n        <div className=\"absolute inset-0 [clip-path:inset(-100vh_-100vw_0_-100vw)]\">\n          <Paper\n            lines={lines}\n            height={height}\n            inside={inside}\n            twist={twist}\n            lift={lift}\n            label={printed ? `Receipt, total ${total}` : undefined}\n            hidden={phase === \"idle\" || phase === \"torn\"}\n            interactive={printed}\n            onPointerDown={(e) => {\n              if (!printed || drag.current || e.button !== 0) return;\n              e.currentTarget.setPointerCapture(e.pointerId);\n              stop();\n              drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY };\n            }}\n            onPointerMove={(e) => {\n              const d = drag.current;\n              if (!d || d.id !== e.pointerId) return;\n              const dx = e.clientX - d.x;\n              const up = d.y - e.clientY;\n              twist.set(MAX_TWIST * Math.tanh(dx / 150));\n              lift.set(-GIVE * Math.tanh(Math.max(up, 0) / (GIVE * 4)));\n              // Rips mid-drag once pulled hard enough, like the real thing,\n              // carrying on in the direction of the hand.\n              if (Math.abs(dx) > TWIST_TEAR || up > LIFT_TEAR) {\n                tear(Math.sign(dx) * 500, up > LIFT_TEAR ? -600 : -200);\n              }\n            }}\n            onPointerUp={release}\n            onPointerCancel={release}\n          />\n          {/* Shade where the paper comes out of the dark of the slot. */}\n          <motion.span\n            aria-hidden\n            style={{ opacity: shade, width: PAPER_WIDTH }}\n            className=\"pointer-events-none absolute bottom-0 left-1/2 h-5 max-w-full -translate-x-1/2 bg-linear-to-t from-black/15 to-transparent\"\n          />\n        </div>\n        {/* The slot's front half, drawn over the paper so it goes into the\n            slot rather than behind the printer's edge. */}\n        <span\n          aria-hidden\n          style={{ background: SLOT }}\n          className=\"pointer-events-none absolute top-full left-1/2 h-[5px] w-[276px] max-w-[calc(100%-24px)] -translate-x-1/2 rounded-b-full\"\n        />\n        {pieces.map((piece) => (\n          <TornPiece\n            key={piece.key}\n            piece={piece}\n            lines={lines}\n            height={height}\n            onGone={() =>\n              setPieces((list) => list.filter((p) => p.key !== piece.key))\n            }\n          />\n        ))}\n      </div>\n\n      <Printer busy={phase === \"printing\"} status={status} />\n\n      <div className=\"mt-4 flex gap-2\">\n        <Button onClick={print}>\n          {phase === \"idle\" ? \"Print receipt\" : \"Reprint\"}\n        </Button>\n        <Button onClick={() => tear(-420, -500)} disabled={!printed}>\n          Tear off\n        </Button>\n      </div>\n    </div>\n  );\n}\n\n// 33 teeth on an 8px pitch across the 264px cutter bar.\nconst CUTTER =\n  \"M0 0\" +\n  Array.from({ length: 33 }, (_, i) => `L${i * 8 + 4} 4L${i * 8 + 8} 0`).join(\"\") +\n  \"Z\";\n\nfunction Printer({ busy, status }: { busy: boolean; status: string }) {\n  return (\n    <div className=\"relative h-[92px] w-full rounded-[22px] bg-linear-to-b from-muted to-[color-mix(in_oklch,var(--muted),var(--foreground)_4%)] shadow-[var(--shadow-raised),inset_0_1px_0_oklch(1_0_0/0.6)] dark:shadow-[var(--shadow-raised),inset_0_1px_0_oklch(1_0_0/0.06)]\">\n      {/* Rubber feet, so the case stands on the page rather than floating. */}\n      <span aria-hidden className=\"absolute -bottom-1 left-7 h-1.5 w-10 rounded-b-md bg-foreground/15\" />\n      <span aria-hidden className=\"absolute right-7 -bottom-1 h-1.5 w-10 rounded-b-md bg-foreground/15\" />\n      {/* The seam of the cover you would lift to load a new roll, with a\n          lit lower edge so it reads as a groove in the molding. */}\n      <span\n        aria-hidden\n        className=\"absolute inset-x-4 top-[34px] h-px bg-foreground/10 shadow-[0_1px_0_oklch(1_0_0/0.7)] dark:shadow-[0_1px_0_oklch(1_0_0/0.05)]\"\n      />\n      {/* The slot the paper leaves through, with the serrated cutter bar\n          along its front lip. */}\n      <div\n        aria-hidden\n        style={{ background: SLOT }}\n        className=\"absolute top-2 left-1/2 h-2.5 w-[276px] max-w-[calc(100%-24px)] -translate-x-1/2 rounded-full\"\n      />\n      <svg\n        aria-hidden\n        viewBox=\"0 0 264 4\"\n        preserveAspectRatio=\"none\"\n        className=\"absolute top-[17px] left-1/2 h-1 w-[264px] max-w-[calc(100%-36px)] -translate-x-1/2 text-foreground/35\"\n      >\n        <path fill=\"currentColor\" d={CUTTER} />\n      </svg>\n      <div className=\"absolute inset-x-5 bottom-[18px] flex items-center justify-between gap-3\">\n        <p className=\"truncate text-sm text-muted-foreground\" aria-live=\"polite\">\n          {status}\n        </p>\n        {/* A status LED is a physical light, so it gets real LED colors:\n            steady green when ready, amber blinking while the motor runs. */}\n        <span aria-hidden className=\"flex shrink-0 items-center gap-2 text-[12px] font-medium tracking-[0.12em] text-muted-foreground uppercase\">\n          {busy ? \"Busy\" : \"Ready\"}\n          <span\n            className={cn(\n              \"size-2 rounded-full transition-[background-color,box-shadow] duration-150 ease-out\",\n              busy && \"animate-pulse\",\n            )}\n            style={{\n              background: busy ? \"oklch(0.8 0.16 75)\" : \"oklch(0.74 0.17 150)\",\n              boxShadow: busy\n                ? \"0 0 6px oklch(0.8 0.16 75 / 0.7)\"\n                : \"0 0 6px oklch(0.74 0.17 150 / 0.6)\",\n            }}\n          />\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction Paper({\n  lines,\n  height,\n  inside,\n  twist,\n  lift,\n  x,\n  opacity,\n  label,\n  hidden,\n  interactive,\n  torn = false,\n  ...handlers\n}: {\n  lines: ReceiptLine[];\n  height: number;\n  inside: MotionValue<number>;\n  twist: MotionValue<number>;\n  lift: MotionValue<number>;\n  x?: MotionValue<number>;\n  opacity?: MotionValue<number>;\n  label?: string;\n  hidden?: boolean;\n  interactive?: boolean;\n  torn?: boolean;\n} & Pick<\n  React.ComponentProps<\"div\">,\n  \"onPointerDown\" | \"onPointerMove\" | \"onPointerUp\" | \"onPointerCancel\"\n>) {\n  const y = useTransform(() => inside.get() + lift.get());\n  return (\n    <motion.div\n      role={label ? \"region\" : undefined}\n      aria-label={label}\n      aria-hidden={hidden || !label || undefined}\n      style={{\n        x,\n        y,\n        rotate: twist,\n        rotateX: LEAN,\n        transformPerspective: 900,\n        opacity,\n        width: PAPER_WIDTH,\n        height,\n        marginLeft: -PAPER_WIDTH / 2,\n      }}\n      className={cn(\n        // Pivots on the slot, where the cutter holds it.\n        \"absolute bottom-0 left-1/2 max-w-full origin-bottom select-none\",\n        // Outside the clip, so the shadow follows the teeth.\n        \"[filter:drop-shadow(0_0_0.5px_oklch(0_0_0/0.2))_drop-shadow(0_6px_10px_oklch(0_0_0/0.1))]\",\n        interactive\n          ? \"cursor-grab touch-none active:cursor-grabbing\"\n          : \"pointer-events-none\",\n      )}\n      {...handlers}\n    >\n      <div\n        style={{\n          clipPath: edge(height, torn),\n          background: PAPER,\n          color: INK,\n          paddingTop: TOOTH + LEAD,\n        }}\n        className=\"h-full px-4 font-mono text-[13px] leading-[18px] uppercase\"\n      >\n        {lines.map((line, i) => (\n          <Line key={i} line={line} />\n        ))}\n      </div>\n    </motion.div>\n  );\n}\n\nfunction Line({ line }: { line: ReceiptLine }) {\n  switch (line.kind) {\n    case \"rule\":\n      return (\n        <p aria-hidden className=\"h-[18px] overflow-hidden whitespace-nowrap opacity-70\">\n          {(line.char ?? \"-\").repeat(48)}\n        </p>\n      );\n    case \"row\":\n      return (\n        <p className=\"flex h-[18px] justify-between gap-3 tabular-nums\">\n          {/* Preserved spaces, so modifier lines keep their indent. */}\n          <span className=\"overflow-hidden text-ellipsis whitespace-pre\">\n            {line.left}\n          </span>\n          <span>{line.right}</span>\n        </p>\n      );\n    case \"total\":\n      return (\n        // Stretched to twice the height at normal width, as thermal\n        // printers do, rather than set in a bigger font.\n        <p className=\"flex h-9 items-start justify-between gap-3 font-bold tabular-nums\">\n          <span className=\"inline-block origin-top scale-y-200\">{line.left}</span>\n          <span className=\"inline-block origin-top scale-y-200\">{line.right}</span>\n        </p>\n      );\n    case \"barcode\":\n      return (\n        <div className=\"flex h-[52px] flex-col items-center justify-center gap-0.5\">\n          <Barcode code={line.code} />\n          <span className=\"text-xs tracking-[0.3em]\">{line.code}</span>\n        </div>\n      );\n    default:\n      return (\n        <p\n          className={cn(\n            \"h-[18px] truncate text-center\",\n            line.kind === \"title\" && \"font-bold tracking-[0.2em]\",\n          )}\n        >\n          {line.text}\n        </p>\n      );\n  }\n}\n\n// Bar widths come from the code, so the same order always prints the same\n// barcode.\nfunction Barcode({ code }: { code: string }) {\n  let seed = [...code].reduce((s, c) => (s * 31 + c.charCodeAt(0)) >>> 0, 7);\n  const next = () => {\n    seed = (seed * 1664525 + 1013904223) >>> 0;\n    return seed / 4294967296;\n  };\n  const bars: { x: number; w: number }[] = [];\n  for (let x = 0; x < 150; ) {\n    const w = 1 + Math.floor(next() * 3);\n    bars.push({ x, w });\n    x += w + 1 + Math.floor(next() * 3);\n  }\n  return (\n    <svg aria-hidden viewBox=\"0 0 152 28\" className=\"h-7 w-[152px]\" fill=\"currentColor\">\n      {bars.map((b) => (\n        <rect key={b.x} x={b.x} width={b.w} height={28} />\n      ))}\n    </svg>\n  );\n}\n\nfunction TornPiece({\n  piece,\n  lines,\n  height,\n  onGone,\n}: {\n  piece: Piece;\n  lines: ReceiptLine[];\n  height: number;\n  onGone: () => void;\n}) {\n  const inside = useMotionValue(0);\n  const x = useMotionValue(0);\n  const lift = useMotionValue(piece.lift);\n  const twist = useMotionValue(piece.twist);\n  const opacity = useMotionValue(1);\n  const gone = useRef(onGone);\n\n  useEffect(() => {\n    gone.current = onGone;\n  });\n\n  useEffect(() => {\n    // Carries on with the hand that tore it, up and away, then fades. An\n    // exit nobody waits on, so it can outlast 300ms.\n    const direction = Math.sign(piece.vx) || -1;\n    const all = [\n      animate(x, piece.vx * 0.25, {\n        type: \"spring\",\n        stiffness: 90,\n        damping: 18,\n        velocity: piece.vx,\n      }),\n      animate(lift, piece.lift - 90, {\n        type: \"spring\",\n        stiffness: 90,\n        damping: 18,\n        velocity: piece.vy,\n      }),\n      animate(twist, piece.twist + direction * 8, {\n        duration: 0.5,\n        ease: EASE_OUT,\n      }),\n      animate(opacity, 0, { duration: 0.26, delay: 0.14, ease: EASE_OUT }),\n    ];\n    all[3].then(() => gone.current());\n    return () => all.forEach((a) => a.stop());\n  }, [piece, x, lift, twist, opacity]);\n\n  return (\n    <div className=\"pointer-events-none absolute inset-0\">\n      <Paper\n        lines={lines}\n        height={height}\n        inside={inside}\n        x={x}\n        twist={twist}\n        lift={lift}\n        opacity={opacity}\n        hidden\n        torn\n      />\n    </div>\n  );\n}\n\nfunction Button({\n  onClick,\n  disabled,\n  children,\n}: {\n  onClick: () => void;\n  disabled?: boolean;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      className=\"h-10 touch-manipulation rounded-full bg-muted px-4 text-sm font-medium text-foreground shadow-raised outline-hidden transition-[scale,opacity] duration-150 ease-out select-none focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground active:scale-[0.96] disabled:opacity-50 disabled:active:scale-100 motion-reduce:transition-[opacity]\"\n    >\n      {children}\n    </button>\n  );\n}\n\nconst LINES: ReceiptLine[] = [\n  { kind: \"title\", text: \"Kettle & Crumb\" },\n  { kind: \"center\", text: \"14 Harbour Row\" },\n  { kind: \"rule\" },\n  { kind: \"row\", left: \"Order #0417\", right: \"23/09/26 09:41\" },\n  { kind: \"rule\" },\n  { kind: \"row\", left: \"2 Flat white\", right: \"9.00\" },\n  { kind: \"row\", left: \"1 Almond croissant\", right: \"4.80\" },\n  { kind: \"row\", left: \"1 Sourdough loaf\", right: \"7.50\" },\n  { kind: \"row\", left: \"  + Oat milk\", right: \"0.60\" },\n  { kind: \"rule\" },\n  { kind: \"row\", left: \"Subtotal\", right: \"21.90\" },\n  { kind: \"row\", left: \"Tax 8%\", right: \"1.75\" },\n  { kind: \"total\", left: \"Total\", right: \"$23.65\" },\n  { kind: \"rule\", char: \"=\" },\n  { kind: \"row\", left: \"Visa **** 4821\", right: \"$23.65\" },\n  { kind: \"barcode\", code: \"0417230926\" },\n  { kind: \"center\", text: \"Thank you, come again\" },\n];\n\nexport default function ReceiptPrinterDemo() {\n  return <ReceiptPrinter lines={LINES} total=\"$23.65\" />;\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":["objects"],"docs":"From ui lab: https://lab.xevrion.dev/lab/receipt-printer"}