{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "marquee",
  "type": "registry:ui",
  "title": "Marquee",
  "description": "Seamless infinite row that eases to a stop under the pointer.",
  "categories": [
    "content"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json"
  ],
  "files": [
    {
      "path": "components/ui/marquee.tsx",
      "content": "\"use client\";\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\n\n// Reduced motion changes the markup here (no repeats, a scrollable row), so it\n// is read hydration-safely: the server and the hydrating render assume motion,\n// then React re-renders with the real preference.\nconst query = \"(prefers-reduced-motion: reduce)\";\nfunction usePrefersReducedMotion() {\n  return useSyncExternalStore(\n    (notify) => {\n      const mq = window.matchMedia(query);\n      mq.addEventListener(\"change\", notify);\n      return () => mq.removeEventListener(\"change\", notify);\n    },\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n\nexport type MarqueeProps = React.HTMLAttributes<HTMLDivElement> & {\n  children: React.ReactNode;\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Pixels per second. The same speed whatever the content length. */\n  speed?: number;\n  /** Scroll right (or down) instead of left (or up). */\n  reverse?: boolean;\n  /** Space between items, and between the end of the content and its repeat, in px. */\n  gap?: number;\n  /** Length of the fade at each edge in px. 0 for hard edges. */\n  fade?: number;\n  /** Ease to a stop while a mouse is over it. It always stops while pressed or while focus is inside. */\n  pauseOnHover?: boolean;\n  /** Stop it from outside, for a visible pause control. It eases to a stop and back up. */\n  paused?: boolean;\n  ref?: React.Ref<HTMLDivElement>;\n};\n\n/**\n * A seamless, infinite row (or column) that eases to a stop under the pointer\n * instead of freezing mid-stride. It repeats its content only as many times as\n * the width needs, keeps the repeats out of the accessibility tree and the tab\n * order, and sleeps while off screen. With reduced motion it becomes a plain\n * scrollable row, so nothing is out of reach.\n */\nexport function Marquee({\n  children,\n  orientation = \"horizontal\",\n  speed = 40,\n  reverse = false,\n  gap = 16,\n  fade = 40,\n  pauseOnHover = true,\n  paused = false,\n  className,\n  style,\n  ref,\n  onPointerEnter,\n  onPointerLeave,\n  onPointerDown,\n  onFocus,\n  onBlur,\n  ...rest\n}: MarqueeProps) {\n  const reduce = usePrefersReducedMotion();\n  const vertical = orientation === \"vertical\";\n  const self = useRef<HTMLDivElement>(null);\n  const track = useRef<HTMLDivElement>(null);\n  const first = useRef<HTMLDivElement>(null);\n  const [copies, setCopies] = useState(1);\n  const [span, setSpan] = useState(0);\n\n  /** How far in the loop starts: the fade plus a little air. */\n  const lead = Math.max(0, fade) + 4;\n  const anim = useRef<Animation | null>(null);\n  const rate = useRef(1);\n  const raf = useRef(0);\n  const holds = useRef({ hover: false, press: false, focus: false, offscreen: false, paused });\n\n  // Glide the playback rate toward 0 or 1 rather than stopping dead: an\n  // exponential approach, about 350ms to rest and a little longer to get going.\n  const steer = useCallback(() => {\n    const a = anim.current;\n    if (!a) return;\n    const h = holds.current;\n    cancelAnimationFrame(raf.current);\n    if (h.offscreen) return a.pause();\n    const target = h.paused || h.hover || h.press || h.focus ? 0 : 1;\n    if (target === 1 && a.playState !== \"running\") a.play();\n    let last = performance.now();\n    const step = (now: number) => {\n      const dt = Math.min(64, now - last);\n      last = now;\n      rate.current += (target - rate.current) * (1 - Math.exp(-dt / (target ? 200 : 110)));\n      if (Math.abs(target - rate.current) < 0.004) rate.current = target;\n      a.playbackRate = rate.current;\n      if (rate.current === 0) return a.pause();\n      if (rate.current !== target) raf.current = requestAnimationFrame(step);\n    };\n    raf.current = requestAnimationFrame(step);\n  }, []);\n\n  const hold = useCallback(\n    (key: \"hover\" | \"press\" | \"focus\" | \"offscreen\" | \"paused\", on: boolean) => {\n      if (holds.current[key] === on) return;\n      holds.current[key] = on;\n      steer();\n    },\n    [steer],\n  );\n\n  useEffect(() => hold(\"paused\", paused), [paused, hold]);\n\n  // Tab onto an item sitting in the faded edge and the row jumps (no animation:\n  // it's keyboard-driven) so the whole item is clear of the fade.\n  const reveal = useCallback(\n    (target: EventTarget) => {\n      const a = anim.current;\n      const root = self.current;\n      const el = track.current;\n      if (!a || !root || !el || !span || !(target instanceof HTMLElement) || !target.matches(\":focus-visible\")) return;\n      // Keyboard focus stops it dead rather than gliding, so the item lands where it's put.\n      cancelAnimationFrame(raf.current);\n      rate.current = 0;\n      a.playbackRate = 0;\n      a.pause();\n      const view = root.getBoundingClientRect();\n      const box = target.getBoundingClientRect();\n      const [start, end, size] = vertical ? [box.top - view.top, box.bottom - view.top, view.height] : [box.left - view.left, box.right - view.left, view.width];\n      const shift = start < lead ? lead - start : end > size - lead ? size - lead - end : 0;\n      if (!shift) return;\n      const matrix = new DOMMatrixReadOnly(getComputedStyle(el).transform);\n      const offset = (vertical ? matrix.m42 : matrix.m41) + shift;\n      // The track runs from +lead to lead - span; find where in that loop this offset falls.\n      const fraction = ((((lead - offset) / span) % 1) + 1) % 1;\n      const duration = (span / speed) * 1000;\n      a.currentTime = (reverse ? 1 - fraction : fraction) * duration;\n    },\n    [span, lead, vertical, speed, reverse],\n  );\n\n  // Measure one copy of the content and the viewport; repeat just enough to cover it.\n  useEffect(() => {\n    const root = self.current;\n    const one = first.current;\n    if (!root || !one || reduce) return;\n    const ro = new ResizeObserver(() => {\n      // Sub-pixel exact, or the loop would jump by the rounding at every wrap. Dividing\n      // by the root's own drawn-vs-layout ratio cancels any scale on an ancestor.\n      const box = root.getBoundingClientRect();\n      const scale = (vertical ? box.height / root.offsetHeight : box.width / root.offsetWidth) || 1;\n      const rect = one.getBoundingClientRect();\n      const size = (vertical ? rect.height : rect.width) / scale;\n      const view = vertical ? root.clientHeight : root.clientWidth;\n      if (!size) return;\n      setSpan(size + gap);\n      setCopies(Math.max(1, Math.ceil(view / (size + gap))));\n    });\n    ro.observe(root);\n    ro.observe(one);\n    return () => ro.disconnect();\n  }, [vertical, gap, reduce]);\n\n  // One animation on the whole track, translating by exactly one copy plus a gap,\n  // so the loop point is invisible. Rebuilt on resize, keeping its progress. The\n  // loop starts `lead` px in, with an inert copy parked before the real items, so\n  // any real item can be brought clear of the leading fade when it takes focus.\n  useEffect(() => {\n    const el = track.current;\n    if (!el || !span || reduce) return;\n    const axis = vertical ? \"Y\" : \"X\";\n    const previous = anim.current;\n    const progress = previous?.effect?.getComputedTiming().progress ?? 0;\n    previous?.cancel();\n    const duration = (span / speed) * 1000;\n    const a = el.animate([{ transform: `translate${axis}(${lead}px)` }, { transform: `translate${axis}(${lead - span}px)` }], {\n      duration,\n      iterations: Infinity,\n      direction: reverse ? \"reverse\" : \"normal\",\n    });\n    a.currentTime = (progress ?? 0) * duration;\n    a.playbackRate = rate.current;\n    anim.current = a;\n    steer();\n    return () => {\n      cancelAnimationFrame(raf.current);\n      a.cancel();\n      if (anim.current === a) anim.current = null;\n    };\n  }, [span, lead, speed, reverse, vertical, reduce, steer]);\n\n  // Nothing moves while nobody can see it.\n  useEffect(() => {\n    const root = self.current;\n    if (!root || reduce) return;\n    const io = new IntersectionObserver(([entry]) => hold(\"offscreen\", !entry.isIntersecting));\n    io.observe(root);\n    return () => io.disconnect();\n  }, [reduce, hold]);\n\n  const group = cn(\"flex shrink-0\", vertical ? \"flex-col\" : \"flex-row items-center\");\n  const edge = `calc(100% - ${fade}px)`;\n\n  return (\n    <div\n      ref={(node) => {\n        self.current = node;\n        if (typeof ref === \"function\") return ref(node);\n        if (ref) ref.current = node;\n      }}\n      // A named region, so aria-label gives the strip a name (and, with reduced motion, the scrollable row a label).\n      role=\"region\"\n      data-marquee=\"\"\n      data-orientation={orientation}\n      data-state={reduce ? \"static\" : \"running\"}\n      tabIndex={reduce ? 0 : undefined}\n      className={cn(\n        \"relative\",\n        // Clip, not hidden: focusing a link in the row can't scroll it out of step.\n        reduce\n          ? cn(\"overscroll-contain rounded-lg outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\", vertical ? \"overflow-y-auto\" : \"overflow-x-auto\")\n          : \"overflow-clip\",\n        !reduce && fade > 0 && (vertical ? \"mask-y-from-(--marquee-edge) mask-y-to-100%\" : \"mask-x-from-(--marquee-edge) mask-x-to-100%\"),\n        className,\n      )}\n      style={{ \"--marquee-edge\": edge, ...style } as React.CSSProperties}\n      onPointerEnter={(e) => {\n        onPointerEnter?.(e);\n        if (pauseOnHover && e.pointerType === \"mouse\") hold(\"hover\", true);\n      }}\n      onPointerLeave={(e) => {\n        onPointerLeave?.(e);\n        hold(\"hover\", false);\n        hold(\"press\", false);\n      }}\n      onPointerDown={(e) => {\n        onPointerDown?.(e);\n        // Press and hold to read, on touch as well as mouse.\n        hold(\"press\", true);\n        const release = () => {\n          hold(\"press\", false);\n          window.removeEventListener(\"pointerup\", release);\n          window.removeEventListener(\"pointercancel\", release);\n        };\n        window.addEventListener(\"pointerup\", release);\n        window.addEventListener(\"pointercancel\", release);\n      }}\n      onFocus={(e) => {\n        onFocus?.(e);\n        hold(\"focus\", true);\n        reveal(e.target);\n      }}\n      onBlur={(e) => {\n        onBlur?.(e);\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) hold(\"focus\", false);\n      }}\n      {...rest}\n    >\n      <div\n        ref={track}\n        className={cn(\"relative flex\", !reduce && \"will-change-transform\", vertical ? \"flex-col\" : \"w-max flex-row\")}\n        // The server render already sits where the loop starts, so nothing jumps when it begins.\n        style={\n          reduce\n            ? // Static and scrollable: a gutter so the first item doesn't sit on the edge.\n              { gap, [vertical ? \"paddingBlock\" : \"paddingInline\"]: 16 }\n            : { gap, transform: vertical ? `translateY(${lead}px)` : `translateX(${lead}px)` }\n        }\n      >\n        {!reduce && (\n          <div\n            aria-hidden\n            inert\n            className={cn(group, \"absolute\", vertical ? \"bottom-full left-0 w-full\" : \"right-full top-0 h-full\")}\n            style={{ gap, [vertical ? \"marginBottom\" : \"marginRight\"]: gap }}\n          >\n            {children}\n          </div>\n        )}\n        <div ref={first} className={group} style={{ gap }}>\n          {children}\n        </div>\n        {!reduce &&\n          Array.from({ length: copies }, (_, i) => (\n            // Repeats are decoration: hidden from assistive tech and unreachable by Tab.\n            <div key={i} aria-hidden inert className={group} style={{ gap }}>\n              {children}\n            </div>\n          ))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/marquee"
}
