{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"confetti-button","type":"registry:component","title":"Confetti button","description":"A click pops confetti; hold it and the fuse burns until the whole thing blows.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/confetti-button.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useImperativeHandle, useRef, useState, type Ref } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { Caveat } from \"next/font/google\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\n// Loaded only where the hint shows, so no other page pays for it.\nconst hand = Caveat({ subsets: [\"latin\"], weight: [\"500\"], preload: false });\n\n// A press shorter than this is a click and gets the small pop.\nconst HOLD_DELAY = 180;\n// From the first shake to detonation. Long enough to build dread, short\n// enough that nobody gives up halfway.\nconst CHARGE_MS = 1500;\n\ntype Burst = {\n  count: number;\n  // Half-angle around straight up; PI is every direction.\n  cone: number;\n  speedMin: number;\n  speedMax: number;\n  lifeMin: number;\n  lifeMax: number;\n  // How far along the button's face pieces start, as a share of its width.\n  spread: number;\n  size: number;\n};\n\n// Enough to read as a pop, few enough that a frame stays well under 1ms.\nconst POP: Burst = {\n  count: 60,\n  cone: (34 * Math.PI) / 180,\n  speedMin: 520,\n  speedMax: 1000,\n  lifeMin: 1.5,\n  lifeMax: 2.3,\n  spread: 1 / 3,\n  size: 1,\n};\n// The detonation: every direction, twice as fast, lingering longer, with\n// some bigger pieces. Still one canvas and one loop.\nconst BLAST: Burst = {\n  count: 360,\n  cone: Math.PI,\n  speedMin: 700,\n  speedMax: 2100,\n  lifeMin: 2,\n  lifeMax: 3.4,\n  spread: 1 / 2,\n  size: 1.35,\n};\n\n// px/s². Paired with heavy drag, paper floats down at gravity / drag\n// instead of dropping like a stone.\nconst GRAVITY = 650;\nconst DRAG = 5;\n// Fades over the last 30% of its life, so nothing blinks out.\nconst FADE = 0.3;\n// Caps a frame after a hitch, so a stalled tab doesn't teleport pieces.\nconst MAX_DT = 1 / 30;\nconst CHECK_FOR = 1400;\n\n// Mostly quiet neutrals, with red-pen marker as the one accent, so it\n// celebrates without shouting and follows the theme.\nconst PALETTE = [\n  { token: \"--foreground\", weight: 0.45 },\n  { token: \"--muted-foreground\", weight: 0.35 },\n  { token: \"--marker\", weight: 0.2 },\n];\n\ntype Particle = {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  w: number;\n  h: number;\n  rotation: number;\n  spin: number;\n  // Flutter: the flip reads as paper turning over, the sway as it drifting\n  // side to side on the way down.\n  flipSpeed: number;\n  swaySpeed: number;\n  sway: number;\n  phase: number;\n  age: number;\n  life: number;\n  color: string;\n  alpha: number;\n};\n\nconst ICON_SWAP = { type: \"spring\", duration: 0.3, bounce: 0 } as const;\n\n// Tokens are light-dark() declarations, which canvas can't parse. Routing\n// each through `color` lets the browser resolve it for the current theme.\nfunction readPalette(el: HTMLElement) {\n  const colors = PALETTE.map(({ token, weight }) => {\n    el.style.color = `var(${token})`;\n    return { color: getComputedStyle(el).color, weight };\n  });\n  el.style.color = \"\";\n  return colors;\n}\n\nfunction pick(colors: { color: string; weight: number }[]) {\n  let r = Math.random();\n  for (const c of colors) {\n    if ((r -= c.weight) <= 0) return c.color;\n  }\n  return colors[0].color;\n}\n\nconst between = (a: number, b: number) => a + Math.random() * (b - a);\n\n/** Drives the button from code, for walkthroughs and previews. */\nexport type ConfettiControls = {\n  /** Starts a hold. `cap` stops the fuse short (below 1 it never blows);\n   * `haptics: false` keeps the phone still. */\n  hold: (options?: { cap?: number; haptics?: boolean }) => void;\n  /** Lets go. `charge` overrides how big the pop is (0 is the plain click pop). */\n  release: (options?: { charge?: number }) => void;\n  /** Drops the hold without celebrating. */\n  cancel: () => void;\n};\nconst vibrate = (ms: number | number[]) => navigator.vibrate?.(ms);\n\nexport function ConfettiButton({\n  children = \"Celebrate\",\n  hint = \"hold me for a surprise\",\n  onCelebrate,\n  controls,\n  className,\n}: {\n  children?: React.ReactNode;\n  // A handwritten nudge pointing at the button, gone after the first blast.\n  // Pass null to leave it out.\n  hint?: string | null;\n  onCelebrate?: (kind: \"pop\" | \"blast\") => void;\n  controls?: Ref<ConfettiControls>;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const buttonRef = useRef<HTMLButtonElement>(null);\n  const fuseRef = useRef<HTMLSpanElement>(null);\n  const ringRef = useRef<HTMLSpanElement>(null);\n  const particles = useRef<Particle[]>([]);\n  const frame = useRef(0);\n  const last = useRef(0);\n  const hold = useRef<{\n    start: number;\n    frame: number;\n    buzzed: number;\n    cap: number;\n    haptics: boolean;\n  } | null>(null);\n  // The canvas covers the whole viewport so a blast can fill the screen; it\n  // mounts on the first press, so a page that never celebrates never pays.\n  const [layer, setLayer] = useState(false);\n  const [checked, setChecked] = useState(false);\n  const [blasted, setBlasted] = useState(false);\n  const checkTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    // Device pixels, so confetti stays crisp.\n    const fit = () => {\n      const dpr = window.devicePixelRatio || 1;\n      canvas.width = Math.round(window.innerWidth * dpr);\n      canvas.height = Math.round(window.innerHeight * dpr);\n    };\n    fit();\n    window.addEventListener(\"resize\", fit);\n    return () => window.removeEventListener(\"resize\", fit);\n  }, [layer]);\n\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(frame.current);\n      if (hold.current) cancelAnimationFrame(hold.current.frame);\n      clearTimeout(checkTimer.current);\n    },\n    [],\n  );\n\n  const tick = (now: number) => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n    const dt = Math.min((now - last.current) / 1000, MAX_DT);\n    last.current = now;\n    const dpr = window.devicePixelRatio || 1;\n    const decay = Math.exp(-DRAG * dt);\n\n    ctx.setTransform(1, 0, 0, 1, 0, 0);\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n    const alive: Particle[] = [];\n    for (const p of particles.current) {\n      p.age += dt;\n      if (p.age >= p.life) continue;\n      p.vx *= decay;\n      p.vy = p.vy * decay + GRAVITY * dt;\n      p.x += (p.vx + Math.sin(p.age * p.swaySpeed + p.phase) * p.sway) * dt;\n      p.y += p.vy * dt;\n      p.rotation += p.spin * dt;\n\n      const flip = Math.cos(p.age * p.flipSpeed + p.phase);\n      const cos = Math.cos(p.rotation);\n      const sin = Math.sin(p.rotation);\n      const remaining = (p.life - p.age) / p.life;\n      ctx.globalAlpha = p.alpha * Math.min(remaining / FADE, 1);\n      ctx.fillStyle = p.color;\n      // Rotate, then squash on one axis: a cheap stand-in for a 3D tumble.\n      ctx.setTransform(\n        dpr * cos,\n        dpr * sin,\n        -dpr * sin * flip,\n        dpr * cos * flip,\n        dpr * p.x,\n        dpr * p.y,\n      );\n      ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h);\n      alive.push(p);\n    }\n    particles.current = alive;\n    ctx.globalAlpha = 1;\n\n    // The loop exists only while something is in the air.\n    frame.current = alive.length > 0 ? requestAnimationFrame(tick) : 0;\n  };\n\n  const run = () => {\n    if (frame.current) return;\n    last.current = performance.now();\n    frame.current = requestAnimationFrame(tick);\n  };\n\n  const spray = (b: Burst, x: number, y: number, width: number) => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const colors = readPalette(canvas);\n    for (let i = 0; i < b.count; i++) {\n      const angle = -Math.PI / 2 + between(-b.cone, b.cone);\n      const speed = between(b.speedMin, b.speedMax);\n      const shape = Math.random();\n      const w = between(5, 9) * b.size;\n      particles.current.push({\n        x: x + between(-width * b.spread, width * b.spread),\n        y,\n        vx: Math.cos(angle) * speed,\n        vy: Math.sin(angle) * speed,\n        w,\n        // Squares, slips and, in a blast, the odd long streamer.\n        h:\n          shape < 0.25\n            ? w\n            : b === BLAST && shape > 0.9\n              ? w * between(1.6, 2.4)\n              : w * between(0.4, 0.55),\n        rotation: between(0, Math.PI * 2),\n        spin: between(-6, 6),\n        flipSpeed: between(6, 14),\n        swaySpeed: between(3, 6),\n        sway: between(15, 40),\n        phase: between(0, Math.PI * 2),\n        age: 0,\n        life: between(b.lifeMin, b.lifeMax),\n        color: pick(colors),\n        alpha: between(0.75, 1),\n      });\n    }\n    run();\n  };\n\n  const origin = () => {\n    const r = buttonRef.current!.getBoundingClientRect();\n    return { x: r.left + r.width / 2, y: r.top + r.height / 2, r };\n  };\n\n  const confirm = () => {\n    setChecked(true);\n    clearTimeout(checkTimer.current);\n    checkTimer.current = setTimeout(() => setChecked(false), CHECK_FOR);\n  };\n\n  const pop = (charge = 0) => {\n    onCelebrate?.(\"pop\");\n    if (reduceMotion) return confirm();\n    const { x, y, r } = origin();\n    // A fuse let go early still counts for something: the pop grows with\n    // how far it burned.\n    spray(\n      {\n        ...POP,\n        count: Math.round(POP.count + 160 * charge),\n        speedMax: POP.speedMax + 700 * charge,\n        cone: POP.cone + charge * 0.8,\n      },\n      x,\n      y,\n      r.width,\n    );\n  };\n\n  const blast = () => {\n    onCelebrate?.(\"blast\");\n    setBlasted(true);\n    confirm();\n    vibrate([40, 30, 80]);\n    if (reduceMotion) return;\n    const { x, y, r } = origin();\n    spray(BLAST, x, y, r.width);\n    // The button takes the recoil: it bursts outward, overshoots back and\n    // wobbles to rest, like something that just went off in your hand.\n    buttonRef.current?.animate(\n      [\n        { transform: \"scale(0.88)\" },\n        { transform: \"scale(1.18) rotate(-3deg)\", offset: 0.18 },\n        { transform: \"scale(0.94) rotate(2deg)\", offset: 0.42 },\n        { transform: \"scale(1.03) rotate(-1deg)\", offset: 0.68 },\n        { transform: \"none\" },\n      ],\n      { duration: 650, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\" },\n    );\n    // The shockwave: one ring, pushed out and faded, compositor only.\n    ringRef.current?.animate(\n      [\n        { transform: \"scale(0.6)\", opacity: 0.55 },\n        { transform: \"scale(5.5)\", opacity: 0 },\n      ],\n      { duration: 620, easing: \"cubic-bezier(0.23, 1, 0.32, 1)\" },\n    );\n  };\n\n  const fuse = (p: number) => {\n    // Burns across the button from the left, like a lit fuse.\n    if (fuseRef.current)\n      fuseRef.current.style.clipPath = `inset(0 ${(1 - p) * 100}% 0 0 round 9999px)`;\n  };\n\n  const settle = () => {\n    const button = buttonRef.current;\n    if (button) {\n      button.style.transform = \"\";\n      button.removeAttribute(\"data-charging\");\n    }\n    fuse(0);\n  };\n\n  const charge = (now: number) => {\n    const h = hold.current;\n    const button = buttonRef.current;\n    if (!h || !button) return;\n    const t = now - h.start;\n    if (t > HOLD_DELAY) {\n      const p = Math.min((t - HOLD_DELAY) / CHARGE_MS, h.cap);\n      button.setAttribute(\"data-charging\", \"\");\n      fuse(p);\n      if (!reduceMotion) {\n        // Shakes harder and faster as it nears the end, squeezing down as if\n        // under pressure. Fresh jitter every frame reads as vibration.\n        const a = 0.4 + 3.2 * p * p;\n        const jx = between(-a, a);\n        const jy = between(-a, a) * 0.6;\n        const rot = between(-1, 1) * 2.5 * p * p;\n        button.style.transform = `translate(${jx}px, ${jy}px) rotate(${rot}deg) scale(${0.96 - 0.08 * p})`;\n        // Sparks spit from the burning end of the fuse, more as it grows.\n        if (between(0, 1) < 0.15 + 0.6 * p) {\n          const r = button.getBoundingClientRect();\n          spray(\n            {\n              ...POP,\n              count: 1 + Math.round(p * 2),\n              cone: 0.9,\n              speedMin: 120,\n              speedMax: 320 + 300 * p,\n              lifeMin: 0.35,\n              lifeMax: 0.8,\n              spread: 0,\n              size: 0.55,\n            },\n            r.left + r.width * p,\n            r.top + r.height / 2,\n            0,\n          );\n        }\n      }\n      // Short buzzes that come closer together, where phones allow it.\n      if (h.haptics && now - h.buzzed > 220 - 150 * p) {\n        vibrate(8);\n        h.buzzed = now;\n      }\n      if (p >= 1) {\n        hold.current = null;\n        settle();\n        blast();\n        return;\n      }\n    }\n    h.frame = requestAnimationFrame(charge);\n  };\n\n  const press = (cap = 1, haptics = true) => {\n    if (hold.current) return;\n    setLayer(true);\n    hold.current = { start: performance.now(), frame: 0, buzzed: 0, cap, haptics };\n    hold.current.frame = requestAnimationFrame(charge);\n  };\n\n  const release = (charge?: number) => {\n    const h = hold.current;\n    if (!h) return;\n    cancelAnimationFrame(h.frame);\n    hold.current = null;\n    const t = performance.now() - h.start;\n    const p = charge ?? Math.min(Math.max((t - HOLD_DELAY) / CHARGE_MS, 0), h.cap);\n    settle();\n    // Let go before it blew: the pop grows with how far the fuse burned.\n    // The canvas mounts on the press, so give it a frame to exist.\n    requestAnimationFrame(() => pop(p));\n  };\n\n  const cancel = () => {\n    const h = hold.current;\n    if (!h) return;\n    cancelAnimationFrame(h.frame);\n    hold.current = null;\n    settle();\n  };\n\n  // Rebuilt every render so it always calls the current handlers.\n  useImperativeHandle(controls, () => ({\n    hold: (o) => press(o?.cap ?? 1, o?.haptics ?? true),\n    release: (o) => release(o?.charge),\n    cancel,\n  }));\n\n  return (\n    <div className={cn(\"relative flex items-center justify-center\", className)}>\n      {layer &&\n        createPortal(\n          <canvas\n            ref={canvasRef}\n            aria-hidden\n            className=\"pointer-events-none fixed inset-0 z-50 size-full\"\n          />,\n          document.body,\n        )}\n      <div className=\"relative\">\n        {hint && <Hint text={hint} gone={blasted} />}\n        {/* Sized to the button, so the shockwave starts at its edge. */}\n        <span\n          ref={ringRef}\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 rounded-full border-2 border-marker opacity-0\"\n        />\n        <button\n          ref={buttonRef}\n          type=\"button\"\n          onPointerDown={(e) => {\n            if (e.button !== 0) return;\n            e.currentTarget.setPointerCapture(e.pointerId);\n            press();\n          }}\n          onPointerUp={() => release()}\n          onPointerCancel={cancel}\n          // A long press is the whole point here; Android would otherwise\n          // answer it with a context menu and cancel the charge.\n          onContextMenu={(e) => e.preventDefault()}\n          onKeyDown={(e) => {\n            if ((e.key === \" \" || e.key === \"Enter\") && !e.repeat) {\n              e.preventDefault();\n              press();\n            }\n          }}\n          onKeyUp={(e) => {\n            if (e.key === \" \" || e.key === \"Enter\") release();\n          }}\n          onBlur={cancel}\n          // Assistive tech can activate without pointer or key events.\n          onClick={(e) => {\n            if (e.detail === 0 && !hold.current) {\n              setLayer(true);\n              requestAnimationFrame(() => pop());\n            }\n          }}\n          aria-describedby={`${id}-how`}\n          className=\"relative h-10 touch-none rounded-full bg-foreground px-5 text-sm font-medium text-background shadow-raised outline-hidden transition-[scale] 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] data-[charging]:transition-none motion-reduce:transition-none\"\n        >\n          {/* The lit fuse: red-pen ink burning across the button. */}\n          <span\n            ref={fuseRef}\n            aria-hidden\n            className=\"absolute inset-0 rounded-full bg-marker\"\n            style={{ clipPath: \"inset(0 100% 0 0 round 9999px)\" }}\n          />\n          {/* Both states share one grid cell, so the swap never resizes it. */}\n          <span className=\"relative grid\">\n            <span\n              className={cn(\n                \"col-start-1 row-start-1 transition-[opacity] duration-200 ease-out\",\n                checked && \"opacity-0\",\n              )}\n            >\n              {children}\n            </span>\n            <span\n              aria-hidden\n              className=\"col-start-1 row-start-1 flex items-center justify-center\"\n            >\n              <motion.svg\n                viewBox=\"0 0 16 16\"\n                className=\"size-4\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                strokeWidth={2}\n                strokeLinecap=\"round\"\n                strokeLinejoin=\"round\"\n                initial={false}\n                animate={\n                  checked\n                    ? { opacity: 1, scale: 1, filter: \"blur(0px)\" }\n                    : { opacity: 0, scale: 0.25, filter: \"blur(4px)\" }\n                }\n                transition={ICON_SWAP}\n              >\n                <path d=\"m3.5 8.5 3 3 6-7\" />\n              </motion.svg>\n            </span>\n          </span>\n        </button>\n      </div>\n      <span id={`${id}-how`} className=\"sr-only\">\n        Click to celebrate, or hold for a bigger one.\n      </span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {checked ? \"Celebrated\" : \"\"}\n      </span>\n    </div>\n  );\n}\n\n// Hand-traced rather than geometric: uneven curves, a loop that isn't quite\n// round, and a head whose barbs differ in length and angle.\nconst ARROW =\n  \"M3 9C14 3.5 29 3 40 10.5C48.5 16.5 49 28 41.5 28.8C34.5 29.6 35.2 18.6 44 19.8C53.5 21 59 33.5 61.2 47.8\" +\n  \"C58.8 44.6 55.8 42.6 52.4 41.8C55.8 42.6 58.8 44.6 61.2 47.8C62.6 44.2 64.4 40.6 67 38.2\";\n\n// Written above and to the left, with an arrow that curls down to the\n// button: the note draws in once when it comes into view.\nfunction Hint({ text, gone }: { text: string; gone: boolean }) {\n  const ref = useRef<HTMLSpanElement>(null);\n  const [drawn, setDrawn] = useState(false);\n\n  useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        setDrawn(true);\n        io.disconnect();\n      },\n      { threshold: 0.6 },\n    );\n    io.observe(el);\n    return () => io.disconnect();\n  }, []);\n\n  return (\n    <span\n      ref={ref}\n      aria-hidden\n      data-drawn={drawn}\n      className={cn(\n        \"group/hint pointer-events-none absolute right-[70%] bottom-[120%] flex w-max flex-col items-end text-marker transition-[opacity,filter,translate] duration-300 ease-out\",\n        // It said its piece; after the first blast it leaves for good.\n        gone && \"-translate-y-1 opacity-0 blur-[2px]\",\n      )}\n    >\n      <span\n        className={cn(\n          hand.className,\n          \"translate-y-1 -rotate-6 text-[22px] leading-none opacity-0 transition-[opacity,translate] delay-100 duration-300 ease-out group-data-[drawn=true]/hint:translate-y-0 group-data-[drawn=true]/hint:opacity-100 motion-reduce:transition-none\",\n        )}\n      >\n        {text}\n      </span>\n      {/* One pen stroke, never lifted: out from the note, a quick doodled\n          loop, down to the button, then both barbs of the head flicked out\n          by doubling back over the first. Drawn as a single path, so the\n          head arrives when the pen gets there instead of appearing on its\n          own. */}\n      <svg\n        viewBox=\"0 0 80 60\"\n        className=\"mt-0.5 mr-[-46px] -mb-1 h-[60px] w-20 overflow-visible\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      >\n        <path\n          d={ARROW}\n          pathLength={1}\n          strokeWidth={2.1}\n          // A pen speeds up through the stroke and slows into the head.\n          className=\"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] delay-300 duration-[900ms] ease-[cubic-bezier(0.45,0,0.25,1)] group-data-[drawn=true]/hint:[stroke-dashoffset:0] motion-reduce:transition-none\"\n        />\n        {/* A second, thinner pass a hair off the first: ink pooling where\n            the nib pressed, so the line isn't a uniform vector stroke. */}\n        <path\n          d={ARROW}\n          pathLength={1}\n          strokeWidth={1.1}\n          opacity={0.55}\n          transform=\"translate(0.6 0.4)\"\n          className=\"[stroke-dasharray:1] [stroke-dashoffset:1] transition-[stroke-dashoffset] delay-300 duration-[900ms] ease-[cubic-bezier(0.45,0,0.25,1)] group-data-[drawn=true]/hint:[stroke-dashoffset:0] motion-reduce:transition-none\"\n        />\n      </svg>\n    </span>\n  );\n}\n\nexport default function ConfettiButtonDemo() {\n  const play = usePreviewPlay();\n  const controls = useRef<ConfettiControls>(null);\n\n  // Index preview: someone holds the button, the fuse burns and it shakes\n  // and spits sparks, then they lose their nerve and let go for a small\n  // pop. Capped short of the end, so a preview can never set off the\n  // full-screen blast, and it never buzzes the phone.\n  useEffect(() => {\n    if (!play) return;\n    // The handle is rebuilt each render; read it at call time.\n    const button = controls;\n    let timer: ReturnType<typeof setTimeout>;\n    const show = () => {\n      button.current?.hold({ cap: 0.75, haptics: false });\n      // 180ms to count as a hold, then about 70% of the fuse.\n      timer = setTimeout(() => {\n        button.current?.release({ charge: 0 });\n        // Confetti settles in about two seconds; then a calm beat.\n        timer = setTimeout(show, 2600);\n      }, 1250);\n    };\n    timer = setTimeout(show, 300);\n    return () => {\n      clearTimeout(timer);\n      button.current?.cancel();\n    };\n  }, [play]);\n\n  return (\n    <ConfettiButton controls={controls} className=\"h-72 w-[360px] max-w-full\" />\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"},{"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"}],"cssVars":{"light":{"marker":"#d93d31","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":{"marker":"#ff6b5f","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":{"color-marker":"var(--marker)","shadow-raised":"var(--shadow-raised)"}},"categories":["buttons"],"docs":"From ui lab: https://lab.xevrion.dev/lab/confetti-button"}