{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"stagger-visualizer","type":"registry:component","title":"Stagger visualizer","description":"Shows what per-item delay actually does to a list entrance.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":[],"registryDependencies":["utils"],"files":[{"path":"components/stagger-visualizer.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\ntype Direction = \"forward\" | \"reverse\" | \"center\" | \"ripple\";\ntype Easing = \"ease-out\" | \"ease-in-out\" | \"ease-in\" | \"linear\";\n\n// Two wide rows keep the grid short enough to see the whole tool at once.\nconst COLS = 6;\nconst ROWS = 2;\nconst COUNT = COLS * ROWS;\n// Each tile's own entrance. The stagger is only the offset between starts.\nconst ENTER = 300;\nconst MAX_DELAY = 120;\n// Longest possible run: the last of 12 tiles at 120ms, plus its entrance.\n// A fixed axis lets you compare settings by eye.\nconst AXIS = (COUNT - 1) * MAX_DELAY + ENTER;\n// Lets a slider come to rest before replaying.\nconst REPLAY_DELAY = 250;\n\nconst EASINGS: Record<Easing, { css: string; short: string; hint: string }> = {\n  \"ease-out\": {\n    css: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n    short: \"out\",\n    hint: \"Right for entrances: each tile starts moving at once.\",\n  },\n  \"ease-in-out\": {\n    css: \"cubic-bezier(0.77, 0, 0.175, 1)\",\n    short: \"in-out\",\n    hint: \"A softer start. The entrance feels a little slower.\",\n  },\n  \"ease-in\": {\n    css: \"cubic-bezier(0.55, 0, 1, 0.45)\",\n    short: \"in\",\n    hint: \"Starts slow, so every tile seems to hesitate. Avoid.\",\n  },\n  linear: {\n    css: \"linear\",\n    short: \"linear\",\n    hint: \"Mechanical. Keep linear for constant motion.\",\n  },\n};\n\nconst DIRECTIONS: { id: Direction; label: string; hint: string }[] = [\n  { id: \"forward\", label: \"Forward\", hint: \"Delay = position in the list × delay per item.\" },\n  { id: \"reverse\", label: \"Reverse\", hint: \"Delay = position from the end × delay per item.\" },\n  { id: \"center\", label: \"Center\", hint: \"Delay = distance from the center, in tiles, × delay per item.\" },\n  { id: \"ripple\", label: \"Ripple\", hint: \"Click any tile to ripple out from it, even mid-run.\" },\n];\n\nconst TICKS = [0, 500, 1000, 1500];\n\n// Distance-based orders measure in grid cells, so tiles the same distance\n// from the origin start together and the entrance spreads out in rings.\nfunction delays(direction: Direction, stagger: number, origin: number) {\n  const center = { col: (COLS - 1) / 2, row: (ROWS - 1) / 2 };\n  const from =\n    direction === \"ripple\"\n      ? { col: origin % COLS, row: Math.floor(origin / COLS) }\n      : center;\n  return Array.from({ length: COUNT }, (_, i) => {\n    if (direction === \"forward\") return i * stagger;\n    if (direction === \"reverse\") return (COUNT - 1 - i) * stagger;\n    const d = Math.hypot(i % COLS - from.col, Math.floor(i / COLS) - from.row);\n    return Math.round(d * stagger);\n  });\n}\n\nfunction verdict(stagger: number) {\n  if (stagger === 0) return \"No stagger. Everything lands at once.\";\n  if (stagger < 30) return \"Barely there. It still reads as one block.\";\n  if (stagger <= 80) return \"Natural. Reads as a sequence without waiting.\";\n  return \"Too slow. The last tiles feel late.\";\n}\n\nexport function StaggerVisualizer({ className }: { className?: string }) {\n  const reduceMotion = useReducedMotion();\n  const [stagger, setStagger] = useState(50);\n  const [direction, setDirection] = useState<Direction>(\"forward\");\n  const [easing, setEasing] = useState<Easing>(\"ease-out\");\n  const [origin, setOrigin] = useState(2);\n  // The hint line explains whichever control was touched last.\n  const [hintFor, setHintFor] = useState<\"direction\" | \"easing\">(\"direction\");\n  const plan = delays(direction, stagger, origin);\n  const last = Math.max(...plan) + ENTER;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const tiles = useRef<(HTMLButtonElement | null)[]>([]);\n  const headRef = useRef<HTMLDivElement>(null);\n  const running = useRef<Animation[]>([]);\n\n  const play = (plan: number[]) => {\n    running.current.forEach((a) => a.cancel());\n    const css = EASINGS[easing].css;\n    // Reduced motion keeps the fade and the timing, and drops the movement.\n    const from = reduceMotion\n      ? { opacity: 0 }\n      : { opacity: 0, transform: \"translateY(8px) scale(0.96)\" };\n    const to = reduceMotion ? { opacity: 1 } : { opacity: 1, transform: \"none\" };\n    const next: Animation[] = [];\n    tiles.current.forEach((tile, i) => {\n      if (!tile) return;\n      // fill: backwards holds each tile hidden through its own delay only;\n      // nothing is disabled, so a click mid-run simply restarts it.\n      next.push(\n        tile.animate([from, to], {\n          duration: ENTER,\n          delay: plan[i],\n          easing: css,\n          fill: \"backwards\",\n        }),\n      );\n    });\n    const head = headRef.current;\n    if (head) {\n      const end = Math.max(...plan) + ENTER;\n      next.push(\n        head.animate(\n          [\n            { transform: \"translateX(0%)\", opacity: 1 },\n            { transform: `translateX(${(end / AXIS) * 100}%)`, opacity: 1, offset: 0.9 },\n            { transform: `translateX(${(end / AXIS) * 100}%)`, opacity: 0 },\n          ],\n          { duration: end / 0.9, easing: \"linear\", fill: \"forwards\" },\n        ),\n      );\n    }\n    running.current = next;\n  };\n\n  const playRef = useRef(() => play(plan));\n  useLayoutEffect(() => {\n    playRef.current = () => play(plan);\n  });\n\n  // Plays once the first time it scrolls into view.\n  useEffect(() => {\n    const el = rootRef.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry.isIntersecting) return;\n        observer.disconnect();\n        playRef.current();\n      },\n      { threshold: 0.4 },\n    );\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n\n  // Any change replays, once the control is left alone for a moment.\n  const key = `${stagger}|${direction}|${easing}`;\n  const lastKey = useRef(key);\n  useEffect(() => {\n    if (lastKey.current === key) return;\n    lastKey.current = key;\n    const id = setTimeout(() => playRef.current(), REPLAY_DELAY);\n    return () => clearTimeout(id);\n  }, [key]);\n\n  useEffect(() => () => running.current.forEach((a) => a.cancel()), []);\n\n  const ripple = (i: number) => {\n    setOrigin(i);\n    setDirection(\"ripple\");\n    setHintFor(\"direction\");\n    // Plays right away instead of waiting for the debounce: the click is\n    // the trigger. Syncing the key stops the effect replaying it again.\n    lastKey.current = `${stagger}|ripple|${easing}`;\n    play(delays(\"ripple\", stagger, i));\n  };\n\n  const hint =\n    hintFor === \"easing\"\n      ? `${EASINGS[easing].hint} Each tile fades up over ${ENTER}ms.`\n      : DIRECTIONS.find((d) => d.id === direction)!.hint;\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        // 12px panels plus 8px of padding keep the corners concentric.\n        \"flex w-[min(520px,100%)] flex-col gap-2 rounded-[20px] bg-muted p-2 text-foreground shadow-raised\",\n        className,\n      )}\n    >\n      <section aria-label=\"Preview\" className=\"rounded-xl bg-background p-3\">\n        <div className=\"mb-3 flex items-center justify-between gap-4\">\n          <p className=\"text-sm leading-5 text-pretty text-muted-foreground\">\n            <span className=\"font-medium text-foreground\">30 to 80ms per item feels natural.</span>{\" \"}\n            Longer starts to feel slow.\n          </p>\n          <button\n            type=\"button\"\n            onClick={() => play(plan)}\n            className=\"flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-foreground pr-4 pl-3 text-sm font-medium text-background 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] motion-reduce:transition-none\"\n          >\n            {/* Nudged right: a triangle's visual center sits left of its box. */}\n            <svg viewBox=\"0 0 16 16\" className=\"size-4 translate-x-px\" fill=\"currentColor\" aria-hidden>\n              <path d=\"M5 3.6v8.8a.6.6 0 0 0 .9.5l7-4.4a.6.6 0 0 0 0-1l-7-4.4a.6.6 0 0 0-.9.5Z\" />\n            </svg>\n            Play\n          </button>\n        </div>\n\n        <div className=\"grid grid-cols-6 gap-2\">\n          {plan.map((delay, i) => (\n            <button\n              key={i}\n              ref={(el) => {\n                tiles.current[i] = el;\n              }}\n              type=\"button\"\n              aria-label={`Ripple from tile ${i + 1}`}\n              onClick={() => ripple(i)}\n              className={cn(\n                \"flex h-12 flex-col justify-between rounded-lg bg-muted p-2 text-left outline-hidden transition-[scale,box-shadow] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n                direction === \"ripple\" && origin === i && \"shadow-[inset_0_0_0_1.5px_var(--foreground)]\",\n              )}\n            >\n              <span aria-hidden className=\"h-1.5 w-3/5 rounded-full bg-foreground/15\" />\n              <span aria-hidden className=\"self-end text-xs leading-none text-muted-foreground tabular-nums\">\n                {delay}\n              </span>\n            </button>\n          ))}\n        </div>\n\n        <figure className=\"mt-3\">\n          <figcaption className=\"mb-1.5 flex items-baseline justify-between gap-3 text-[13px] text-muted-foreground\">\n            <span>Each row is a tile, in grid order</span>\n            <span>\n              Done at <span className=\"text-foreground tabular-nums\">{last}ms</span>\n            </span>\n          </figcaption>\n          <div className=\"relative\">\n            <div aria-hidden className=\"flex flex-col gap-px rounded-md bg-muted p-1.5\">\n              {plan.map((delay, i) => (\n                <div key={i} className=\"h-[3px] w-full\">\n                  {/* Width is one entrance on the shared axis; translating by\n                      delay / ENTER of its own width puts it at the delay. */}\n                  <div\n                    className=\"h-full rounded-full bg-foreground transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none\"\n                    style={{\n                      width: `${(ENTER / AXIS) * 100}%`,\n                      transform: `translateX(${(delay / ENTER) * 100}%)`,\n                    }}\n                  />\n                </div>\n              ))}\n            </div>\n            <div aria-hidden className=\"pointer-events-none absolute inset-y-0 right-1.5 left-1.5\">\n              <div ref={headRef} className=\"h-full w-full opacity-0\">\n                <div className=\"h-full w-px -translate-x-1/2 bg-muted-foreground\" />\n              </div>\n            </div>\n          </div>\n          {/* Ticks sit at their true place on the axis, which ends at AXIS, not 1500. */}\n          <div aria-hidden className=\"relative mx-1.5 mt-1 h-4 text-xs text-muted-foreground tabular-nums\">\n            {TICKS.map((t) => (\n              <span\n                key={t}\n                className={cn(\"absolute top-0\", t > 0 && \"-translate-x-1/2\")}\n                style={{ left: `${(t / AXIS) * 100}%` }}\n              >\n                {t}ms\n              </span>\n            ))}\n          </div>\n          <p className=\"sr-only\" aria-live=\"polite\">\n            Last tile finishes at {last} milliseconds.\n          </p>\n        </figure>\n      </section>\n\n      <section aria-label=\"Settings\" className=\"flex flex-col gap-3 rounded-xl bg-background p-3\">\n        <div className=\"grid gap-3 sm:grid-cols-2 sm:gap-4\">\n          <div className=\"flex flex-col gap-1.5\">\n            <div className=\"flex items-baseline justify-between gap-2\">\n              <label htmlFor=\"stagger-delay\" className=\"text-sm font-medium\">\n                Delay per item\n              </label>\n              <output htmlFor=\"stagger-delay\" className=\"text-sm tabular-nums\">\n                {stagger}ms\n              </output>\n            </div>\n            <input\n              id=\"stagger-delay\"\n              type=\"range\"\n              min={0}\n              max={MAX_DELAY}\n              step={5}\n              value={stagger}\n              aria-describedby=\"stagger-verdict\"\n              onChange={(e) => setStagger(Number(e.target.value))}\n              style={{ \"--fill\": `${(stagger / MAX_DELAY) * 100}%` } as React.CSSProperties}\n              className={cn(\n                \"h-8 w-full cursor-pointer appearance-none rounded-full bg-transparent outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n                // The filled part is a hard color stop at the value, not a blend.\n                \"[&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--foreground)_var(--fill),var(--border)_var(--fill))]\",\n                \"[&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[linear-gradient(to_right,var(--foreground)_var(--fill),var(--border)_var(--fill))]\",\n                // -6px centers the 16px thumb on the 4px track.\n                \"[&::-webkit-slider-thumb]:-mt-1.5 [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-raised\",\n                \"[&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-raised\",\n              )}\n            />\n            {/* The 30 to 80ms band, drawn under the slider's own scale. mx-2\n                matches the thumb's travel, which stops 8px short of each end. */}\n            <div aria-hidden className=\"relative mx-2 h-1.5\">\n              <div\n                className=\"absolute inset-y-0 rounded-full bg-foreground/15\"\n                style={{ left: `${(30 / MAX_DELAY) * 100}%`, right: `${100 - (80 / MAX_DELAY) * 100}%` }}\n              />\n            </div>\n          </div>\n\n          <Choice\n            label=\"Easing\"\n            value={easing}\n            valueLabel={easing}\n            options={(Object.keys(EASINGS) as Easing[]).map((id) => ({ id, label: EASINGS[id].short }))}\n            onChange={(e) => {\n              setEasing(e);\n              setHintFor(\"easing\");\n            }}\n            mono\n          />\n        </div>\n\n        <Choice\n          label=\"Direction\"\n          value={direction}\n          options={DIRECTIONS}\n          onChange={(d) => {\n            setDirection(d);\n            setHintFor(\"direction\");\n          }}\n        />\n\n        {/* Two lines are reserved for each note so a longer message never\n            changes the demo's height. */}\n        <div className=\"flex flex-col border-t border-border pt-3 text-[13px] leading-5 text-pretty text-muted-foreground\">\n          <p id=\"stagger-verdict\" className=\"min-h-10 text-foreground sm:min-h-5\">\n            {verdict(stagger)}\n          </p>\n          <p id=\"stagger-hint\" className=\"min-h-10\">\n            {hint}\n          </p>\n        </div>\n      </section>\n    </div>\n  );\n}\n\nfunction Choice<T extends string>({\n  label,\n  options,\n  value,\n  valueLabel,\n  onChange,\n  mono,\n}: {\n  label: string;\n  options: { id: T; label: string }[];\n  value: T;\n  valueLabel?: string;\n  onChange: (value: T) => void;\n  mono?: boolean;\n}) {\n  const id = `stagger-${label.toLowerCase()}`;\n  return (\n    <div className=\"flex flex-col gap-1.5\">\n      <div className=\"flex items-baseline justify-between gap-2\">\n        <span id={id} className=\"text-sm font-medium\">\n          {label}\n        </span>\n        {valueLabel && <span className=\"font-mono text-[13px] text-muted-foreground\">{valueLabel}</span>}\n      </div>\n      <div\n        role=\"radiogroup\"\n        aria-labelledby={id}\n        aria-describedby=\"stagger-hint\"\n        className=\"grid grid-cols-4 gap-1 rounded-[12px] bg-muted p-1\"\n        onKeyDown={(e) => {\n          const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];\n          if (!step) return;\n          e.preventDefault();\n          const index = options.findIndex((o) => o.id === value);\n          const next = options[(index + step + options.length) % options.length];\n          onChange(next.id);\n          e.currentTarget.querySelector<HTMLElement>(`[data-id=\"${next.id}\"]`)?.focus();\n        }}\n      >\n        {options.map((o) => (\n          <button\n            key={o.id}\n            type=\"button\"\n            role=\"radio\"\n            data-id={o.id}\n            aria-checked={value === o.id}\n            // Short easing labels (\"in\") mean little read aloud on their own.\n            aria-label={mono ? o.id : undefined}\n            tabIndex={value === o.id ? 0 : -1}\n            onClick={() => onChange(o.id)}\n            className={cn(\n              // 8px radius + 4px padding = the 12px group.\n              \"h-8 min-w-0 truncate rounded-[8px] px-1 text-[13px] font-medium outline-hidden transition-[color,background-color,scale] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n              mono && \"font-mono font-normal\",\n              value === o.id ? \"bg-background text-foreground shadow-raised\" : \"text-muted-foreground hover:text-foreground\",\n            )}\n          >\n            {o.label}\n          </button>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nexport default function StaggerVisualizerDemo() {\n  return <StaggerVisualizer />;\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":["playground"],"docs":"From ui lab: https://lab.xevrion.dev/lab/stagger-visualizer"}