{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "echo-reel",
  "type": "registry:ui",
  "title": "Echo Reel",
  "description": "A giant word printed solid, with the words before and after it in the list stacked above and below as pale halftone proofs. Every few seconds the reel turns one row: the proof below rises into the middle while one roller pass moves the ink off the old word and onto the new one. Click a proof or press ↑ ↓ to turn it yourself, so it works as a headline that cycles who it's for or as a picker for plans.",
  "author": "KeyframeUI <https://keyframeui.com>",
  "categories": [
    "text",
    "hero",
    "motion"
  ],
  "docs": "Live preview, examples and props: https://keyframeui.com/components/echo-reel",
  "dependencies": [
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "components/ui/echo-reel.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Keep:\n * - Rows move by `transform` and `opacity` only. The ink wipes by sliding its clipping window one\n *   way and the word inside it the other by the same amount, on one `rollTransition`, so the word\n *   never shifts.\n * - Rows are keyed by absolute slot and the reel only turns the short way round, so each row keeps\n *   its element through a wrap; `reach` mounts enough rows that a jump never mounts one in view.\n * - On a turn the roller trails the reel slightly on an in-out curve like the reel's, so the ink is\n *   handed over while the rows travel. `print` times only the first inking on view, before any turn.\n * - Proof buttons are keyed by side, not by word, so focus stays put as the reel turns. A visitor's\n *   turn stops autoplay for good and is announced politely; autoplay turns are not.\n * - Reduced motion swaps the rows at once and fades the new ink in over its proof, which drops only\n *   once that fade ends. The reduced flag reads false while hydrating, so the markup matches.\n */\nconst TIMING = {\n  /** The reel moving one row, in ms, on its strong in-out curve. */\n  turn: { duration: 700, ease: \"cubic-bezier(0.77, 0, 0.175, 1)\" },\n  /**\n   * The roller moving the ink across on a turn, in ms: its run, its lag behind the reel, its curve.\n   * On a turn the roller rides the reel's own curve, so the ink crosses while the rows travel\n   * instead of landing before they've moved.\n   */\n  roll: { duration: 640, delay: 60, ease: \"cubic-bezier(0.65, 0, 0.35, 1)\" },\n  /** The first ink when the reel comes into view, in ms. */\n  print: { duration: 700, delay: 150, ease: \"cubic-bezier(0.23, 1, 0.32, 1)\" },\n  /** A word's proof going once the ink covers it, in ms: its fade, the wait first, its curve. */\n  proof: { duration: 300, hold: 450, ease: \"ease-out\" },\n  /** A row leaving or joining the visible stack, in ms, and its curve. */\n  fade: { duration: 400, ease: \"ease-out\" },\n  /** The ink fading in place under reduced motion, in ms, and its curve. */\n  reduced: { duration: 250, ease: \"ease-out\" },\n  /** Between turns by itself, in seconds (the `interval` prop). */\n  interval: 3,\n  /** How fast each turn plays, as a multiple of the default pace (the `speed` prop). */\n  speed: 1,\n  /** The slowest pace `speed` is held to, as a multiple of the default pace. */\n  minSpeed: 0.05,\n} as const;\n\n/* ─────────────────────────────────────────────────────────\n * ECHO REEL STORYBOARD\n *\n *  rest     one giant word printed solid in the ink. The words\n *           before and after it in the list sit above and below\n *           as pale halftone proofs, each row overlapping the\n *           next, like the impressions round a print reel\n *  view     the first time it shows, the middle word is still a\n *           proof. 150ms later a roller inks it, left to right,\n *           in 0.7s\n *  turn     every `interval` seconds the reel turns one row in\n *           0.7s (strong in-out). One roller pass, 60ms behind and\n *           on the same in-out curve, moves the ink across: it\n *           comes off the old word left to right as that word\n *           rises into the top proof, and goes onto the word rising\n *           into the middle at the same edge, so the ink is handed\n *           over in transit. The row leaving the top fades as it\n *           goes; the next word fades in below\n *  back     a turn the other way runs in reverse: the reel drops\n *           and the roller travels right to left\n *  pick     click a proof, or focus it and press ↑ ↓, and the reel\n *           turns to that word. A visitor's turn stops the\n *           autoplay for good and is announced politely; autoplay\n *           turns are not announced\n *  hover    a proof under the pointer darkens a step, so it reads\n *           as something to press. Hover or focus on the reel\n *           holds the autoplay\n *  sleep    autoplay waits while the reel is off screen or the tab\n *           is hidden\n *  reduced  nothing travels and nothing wipes: the rows swap at\n *           once and the new word's ink fades in over its proof\n *           in 0.25s\n * ───────────────────────────────────────────────────────── */\n\nconst REEL = {\n  step: 0.68, // em between one row and the next; rows are 1.32em tall, so they overlap\n  pad: { x: 0.08, y: 0.16 }, // em around each word, so a wipe never clips a glyph\n  fit: 0.94, // share of the width the longest word fills\n  far: 0.5, // opacity of the outer proofs with echoes={2}\n  measure: 100, // px the words are measured at\n} as const;\n\nconst ROW = 1 + REEL.pad.y * 2; // em, one row's height\nconst STEP_PCT = (REEL.step / ROW) * 100; // a row's step as a share of its own height\nconst CAP_TOP = 0.29; // em from a row's top to its capitals\nconst BASELINE = 1.01; // em from a row's top to its baseline\n\nconst mod = (a: number, n: number) => ((a % n) + n) % n;\n\n/** The signed shortest way round a reel of `n` from index `a` to `b`; a tie goes forward. */\nfunction shortest(a: number, b: number, n: number) {\n  const d = mod(b - a, n);\n  return d > n / 2 ? d - n : d;\n}\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReduced(onChange: () => void) {\n  const query = window.matchMedia(REDUCED_QUERY);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n}\n\n/** Live, and false on the server and in the hydration render, so the markup matches. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReduced,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\nexport interface EchoReelProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> {\n  /** The words on the reel, in order. The next word waits below the current one and the previous one sits above. A single word prints a still stack of itself. */\n  words: string[];\n  /** The index of the word in the middle, when the parent owns it. Pair it with `onValueChange`. */\n  value?: number;\n  /** The index the reel starts on when it runs by itself. */\n  defaultValue?: number;\n  /** Called with the new index each time the reel turns, and whether autoplay or the visitor turned it. */\n  onValueChange?: (index: number, reason: \"auto\" | \"user\") => void;\n  /** Seconds between turns by itself, or `false` to wait for the visitor. Hover and focus hold it, and the visitor's first turn stops it. */\n  interval?: number | false;\n  /** How many proofs sit on each side of the word. */\n  echoes?: 1 | 2;\n  /** How fast each turn plays, as a multiple of the default pace: the reel, the roller, the first print and the fades together. `2` is twice as fast. */\n  speed?: number;\n}\n\n/* ── Row ──────────────────────────────────────────────── */\n// One word on the reel: its halftone proof, and the ink over it behind a\n// window that slides across to roll the ink on or off.\n\ntype RowProps = {\n  word: string;\n  offset: number;\n  echoes: number;\n  inked: boolean;\n  first: boolean;\n  hot: boolean;\n  reduced: boolean;\n  speed: number;\n};\n\nconst Row = React.memo(function Row({ word, offset, echoes, inked, first, hot, reduced, speed }: RowProps) {\n  const away = Math.abs(offset);\n  const solid = offset === 0 && inked;\n  // Where the ink's window sits: over the word, waiting to its left (rows still\n  // to come, below), or gone off to its right (rows already printed, above).\n  const gate = solid ? 0 : offset < 0 ? 100 : -100;\n  const roll = solid && first ? TIMING.print : TIMING.roll;\n  // Every move on a turn, scaled together by `speed`.\n  const ms = (t: number) => t / speed;\n  const rollTransition = reduced ? \"none\" : `transform ${ms(roll.duration)}ms ${roll.ease} ${ms(roll.delay)}ms`;\n\n  const pad = `${REEL.pad.y}em ${REEL.pad.x}em`;\n\n  return (\n    <div\n      className=\"absolute inset-x-0 flex justify-center\"\n      style={{\n        top: `${echoes * REEL.step}em`,\n        height: `${ROW}em`,\n        zIndex: 10 - away,\n        transform: `translateY(${offset * STEP_PCT}%)`,\n        opacity: away > echoes ? 0 : away === 2 ? REEL.far : 1,\n        visibility: away > echoes + 1 ? \"hidden\" : undefined,\n        willChange: away <= 1 ? \"transform\" : undefined,\n        transition: reduced\n          ? \"none\"\n          : `transform ${ms(TIMING.turn.duration)}ms ${TIMING.turn.ease}, opacity ${ms(TIMING.fade.duration)}ms ${TIMING.fade.ease}`,\n        ...(hot\n          ? ({ \"--echo-reel-tint\": \"color-mix(in oklab, var(--echo-reel-ink) 62%, transparent)\" } as React.CSSProperties)\n          : null),\n      }}\n    >\n      <span className=\"relative block whitespace-nowrap\" style={{ padding: pad }}>\n        <span className=\"invisible\">{word}</span>\n\n        {/* The proof: a pale fill and a hairline, with a halftone over it. */}\n        <span\n          className=\"absolute inset-0\"\n          style={{\n            opacity: solid ? 0 : 1,\n            transition: reduced\n              ? solid\n                ? `opacity 0s linear ${TIMING.reduced.duration}ms`\n                : \"none\"\n              : `opacity ${ms(TIMING.proof.duration)}ms ${TIMING.proof.ease} ${solid ? ms(TIMING.proof.hold) : 0}ms`,\n          }}\n        >\n          <span\n            className=\"absolute inset-0\"\n            style={{\n              padding: pad,\n              WebkitTextFillColor: \"transparent\", // not `color`, which would turn the tint (from currentColor) transparent too\n              WebkitTextStroke: \"max(1px, 0.005em) var(--echo-reel-tint)\",\n              backgroundImage:\n                \"linear-gradient(color-mix(in oklab, var(--echo-reel-tint) 26%, transparent) 0 0)\",\n              WebkitBackgroundClip: \"text\",\n              backgroundClip: \"text\",\n            }}\n          >\n            {word}\n          </span>\n          <span\n            className=\"absolute inset-0\"\n            style={{\n              padding: pad,\n              WebkitTextFillColor: \"transparent\", // not `color`, which would turn the tint (from currentColor) transparent too\n              // Two offset grids of dots make the 45° screen of a halftone.\n              backgroundImage:\n                \"radial-gradient(circle closest-side, var(--echo-reel-tint) 0 44%, transparent 58%), radial-gradient(circle closest-side, var(--echo-reel-tint) 0 44%, transparent 58%)\",\n              backgroundSize: \"var(--echo-reel-dot) var(--echo-reel-dot)\",\n              backgroundPosition: \"0 0, calc(var(--echo-reel-dot) / 2) calc(var(--echo-reel-dot) / 2)\",\n              WebkitBackgroundClip: \"text\",\n              backgroundClip: \"text\",\n              maskImage: \"radial-gradient(ellipse 62% 78% at 50% 58%, #000 25%, transparent 100%)\",\n              WebkitMaskImage: \"radial-gradient(ellipse 62% 78% at 50% 58%, #000 25%, transparent 100%)\",\n            }}\n          >\n            {word}\n          </span>\n        </span>\n\n        {/* The ink, behind a window that slides across while the word stays put. */}\n        <span\n          className=\"absolute inset-0 overflow-hidden\"\n          style={{\n            transform: reduced ? undefined : `translateX(${gate}%)`,\n            opacity: reduced ? (solid ? 1 : 0) : undefined,\n            willChange: away <= 1 && !reduced ? \"transform\" : undefined,\n            transition: reduced\n              ? solid\n                ? `opacity ${TIMING.reduced.duration}ms ${TIMING.reduced.ease}`\n                : \"none\"\n              : rollTransition,\n          }}\n        >\n          <span\n            className=\"block size-full\"\n            style={{\n              padding: pad,\n              color: \"var(--echo-reel-ink)\",\n              transform: reduced ? undefined : `translateX(${-gate}%)`,\n              willChange: away <= 1 && !reduced ? \"transform\" : undefined,\n              transition: rollTransition,\n            }}\n          >\n            {word}\n          </span>\n        </span>\n      </span>\n    </div>\n  );\n});\n\n/* ── EchoReel ─────────────────────────────────────────── */\n\nexport function EchoReel({\n  words,\n  value,\n  defaultValue = 0,\n  onValueChange,\n  interval = TIMING.interval,\n  echoes = 1,\n  speed = TIMING.speed,\n  className,\n  style,\n  onPointerEnter,\n  onPointerLeave,\n  onFocus,\n  onBlur,\n  ...props\n}: EchoReelProps) {\n  const n = words.length;\n  const spins = n > 1;\n  const reduced = usePrefersReducedMotion();\n\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const reelRef = React.useRef<HTMLDivElement>(null);\n  const measureRef = React.useRef<HTMLSpanElement>(null);\n\n  // The reel's absolute position: slot k shows words[k mod n]. It only ever\n  // moves the short way round, so each row keeps its element through a wrap.\n  const [slot, setSlot] = React.useState(() => value ?? defaultValue);\n  const [seen, setSeen] = React.useState(value);\n  if (spins && value !== undefined && value !== seen) {\n    setSeen(value);\n    setSlot(slot + shortest(mod(slot, n), value, n));\n  }\n  const index = n ? mod(slot, n) : 0;\n\n  const [printed, setPrinted] = React.useState(false);\n  const [turned, setTurned] = React.useState(false);\n  const [inView, setInView] = React.useState(false);\n  const [visible, setVisible] = React.useState(true);\n  const [hovered, setHovered] = React.useState(false);\n  const [focused, setFocused] = React.useState(false);\n  const [stopped, setStopped] = React.useState(false);\n  const [hot, setHot] = React.useState<number | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const inked = printed || reduced;\n\n  const turn = (delta: number, reason: \"auto\" | \"user\") => {\n    if (!spins || delta === 0) return;\n    const next = mod(slot + delta, n);\n    setSlot(slot + delta);\n    setTurned(true);\n    if (value !== undefined) setSeen(next);\n    if (reason === \"user\") {\n      setStopped(true);\n      setAnnounce(words[next]);\n    }\n    onValueChange?.(next, reason);\n  };\n\n  // The timer calls the latest `turn` without restarting on every render.\n  const turnRef = React.useRef(turn);\n  React.useLayoutEffect(() => {\n    turnRef.current = turn;\n  });\n\n  const every = interval === false ? 0 : interval;\n  const playing = spins && every > 0 && inked && inView && visible && !stopped && !hovered && !focused;\n\n  React.useEffect(() => {\n    if (!playing) return;\n    const id = window.setTimeout(() => turnRef.current(1, \"auto\"), every * 1000);\n    return () => window.clearTimeout(id);\n  }, [playing, every, slot]);\n\n  // Print on first view; autoplay sleeps off screen.\n  React.useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const io = new IntersectionObserver(\n      ([entry]) => {\n        setInView(entry.isIntersecting);\n        if (entry.isIntersecting) setPrinted(true);\n      },\n      { threshold: 0.25 },\n    );\n    io.observe(root);\n    return () => io.disconnect();\n  }, []);\n\n  React.useEffect(() => {\n    const sync = () => setVisible(!document.hidden);\n    document.addEventListener(\"visibilitychange\", sync);\n    return () => document.removeEventListener(\"visibilitychange\", sync);\n  }, []);\n\n  // Fit the longest word to the width once the font is in, and on every resize.\n  const key = words.join(\"\\n\");\n  React.useLayoutEffect(() => {\n    const root = rootRef.current;\n    const reel = reelRef.current;\n    const measure = measureRef.current;\n    if (!root || !reel || !measure) return;\n    let alive = true;\n    const fit = () => {\n      if (!alive) return;\n      let widest = 0;\n      for (const child of Array.from(measure.children)) {\n        widest = Math.max(widest, child.getBoundingClientRect().width);\n      }\n      const width = root.clientWidth;\n      if (!widest || !width) return;\n      reel.style.fontSize = `${((width * REEL.fit) / widest) * REEL.measure}px`;\n    };\n    fit();\n    const ro = new ResizeObserver(fit);\n    ro.observe(root);\n    document.fonts?.ready.then(fit);\n    return () => {\n      alive = false;\n      ro.disconnect();\n    };\n  }, [key]);\n\n  if (!n) return null;\n\n  const longest = Math.max(...words.map((word) => word.length), 1);\n  // Before measuring (and on the server): roughly 0.56em a letter at this weight.\n  const fallback = `${((REEL.fit * 100) / (longest * 0.56)).toFixed(2)}cqi`;\n\n  // Enough rows either side that a jump the short way round never mounts a\n  // row where it can be seen.\n  const reach = spins ? echoes + 1 + Math.floor(n / 2) : echoes;\n  const rows: number[] = [];\n  for (let k = slot - reach; k <= slot + reach; k++) rows.push(k);\n\n  const sides: number[] = [];\n  if (spins) for (let o = -echoes; o <= echoes; o++) if (o !== 0) sides.push(o);\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"ArrowDown\" || e.key === \"ArrowRight\") {\n      e.preventDefault();\n      turn(1, \"user\");\n    } else if (e.key === \"ArrowUp\" || e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      turn(-1, \"user\");\n    }\n  };\n\n  return (\n    <div\n      ref={rootRef}\n      className={cn(\n        \"@container/echo-reel relative w-full select-none [--echo-reel-ink:currentColor] [--echo-reel-tint:color-mix(in_oklab,var(--echo-reel-ink)_38%,transparent)] [--echo-reel-dot:clamp(4px,0.028em,8px)]\",\n        className,\n      )}\n      style={style}\n      onPointerEnter={(e) => {\n        if (e.pointerType === \"mouse\") setHovered(true);\n        onPointerEnter?.(e);\n      }}\n      onPointerLeave={(e) => {\n        setHovered(false);\n        setHot(null);\n        onPointerLeave?.(e);\n      }}\n      onFocus={(e) => {\n        setFocused(true);\n        onFocus?.(e);\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);\n        onBlur?.(e);\n      }}\n      {...props}\n    >\n      <span className=\"sr-only\">{words[index]}</span>\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {announce}\n      </span>\n\n      <div\n        ref={reelRef}\n        className=\"relative font-black leading-none tracking-[-0.06em] [overflow:clip]\"\n        style={{ fontSize: fallback, height: `${ROW + echoes * 2 * REEL.step}em` }}\n      >\n        <div aria-hidden className=\"absolute inset-0\">\n          {rows.map((k) => (\n            <Row\n              key={k}\n              word={words[mod(k, n)]}\n              offset={k - slot}\n              echoes={echoes}\n              inked={inked}\n              first={!turned}\n              hot={hot === k - slot}\n              reduced={reduced}\n              speed={Math.max(TIMING.minSpeed, speed)}\n            />\n          ))}\n        </div>\n\n        {/* A button over each proof's visible band. Kept by side, not by word,\n            so focus stays put while the reel turns under it. */}\n        {sides.map((o) => (\n          <button\n            key={o}\n            type=\"button\"\n            aria-label={`Show ${words[mod(slot + o, n)]}`}\n            className=\"absolute inset-x-0 z-20 cursor-pointer rounded-[0.12em] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--echo-reel-ink)]\"\n            style={{\n              top: `${(echoes + (o < 0 ? o : o - 1)) * REEL.step + (o < 0 ? CAP_TOP : BASELINE)}em`,\n              height: `${REEL.step}em`,\n            }}\n            onPointerEnter={(e) => {\n              if (e.pointerType === \"mouse\") setHot(o);\n            }}\n            onPointerLeave={() => setHot(null)}\n            onFocus={() => setHot(o)}\n            onBlur={() => setHot(null)}\n            onClick={() => turn(o, \"user\")}\n            onKeyDown={onKeyDown}\n          />\n        ))}\n      </div>\n\n      {/* Every word at a fixed size, for fitting the longest to the width. */}\n      <span\n        ref={measureRef}\n        aria-hidden\n        className=\"pointer-events-none invisible absolute top-0 left-0 font-black leading-none tracking-[-0.06em] whitespace-nowrap\"\n        style={{ fontSize: REEL.measure }}\n      >\n        {words.map((word, i) => (\n          <span key={i} className=\"absolute top-0 left-0\">\n            {word}\n          </span>\n        ))}\n      </span>\n    </div>\n  );\n}\n"
    }
  ]
}