{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "infinite-marquee",
  "type": "registry:ui",
  "title": "Infinite Marquee",
  "description": "A seamless horizontal marquee that eases to a stop on hover and never jumps.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/infinite-marquee.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface InfiniteMarqueeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Items to scroll. Each child becomes one list item. */\n  children: React.ReactNode;\n  /** Travel direction. */\n  direction?: \"left\" | \"right\";\n  /** Speed in pixels per second. */\n  speed?: number;\n  /** Space between items in px. */\n  gap?: number;\n  /** Ease to a stop while a mouse hovers the marquee. */\n  pauseOnHover?: boolean;\n  /** Fade the left and right edges. */\n  fade?: boolean;\n  /** Show a small pause / play button (recommended for long-running marquees, WCAG 2.2.2). */\n  controls?: boolean;\n}\n\n/* --------------------------- reduced-motion hook --------------------------- */\n\nconst REDUCED_QUERY = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(REDUCED_QUERY);\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(REDUCED_QUERY).matches,\n    () => false,\n  );\n}\n\n/** True when focus is inside the marquee, ignoring its own pause / play button. */\nfunction isFocusWithin(root: HTMLElement) {\n  const active = document.activeElement;\n  return !!active && root.contains(active) && !(active instanceof HTMLElement && \"marqueeControl\" in active.dataset);\n}\n\nconst FADE_MASK = \"linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)\";\n\n/**\n * A seamless horizontal marquee. The item group is repeated just enough times to cover the\n * viewport and the track is translated by exactly one group width with the Web Animations API,\n * so the loop never jumps. It slows to a stop on hover and focus, and pauses offscreen.\n */\nexport function InfiniteMarquee({\n  children,\n  direction = \"left\",\n  speed = 48,\n  gap = 40,\n  pauseOnHover = true,\n  fade = true,\n  controls = false,\n  className = \"\",\n  style,\n  ...props\n}: InfiniteMarqueeProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const groupRef = React.useRef<HTMLUListElement>(null);\n  // Horizontal position as a fraction of one group width — survives option changes without a jump.\n  const positionRef = React.useRef(0);\n  const [copies, setCopies] = React.useState(2);\n  const [userPaused, setUserPaused] = React.useState(false);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const track = trackRef.current;\n    const group = groupRef.current;\n    if (reduceMotion || !root || !track || !group) return;\n\n    let animation: Animation | null = null;\n    let width = 0;\n    let rateFrame = 0;\n    // Restore pointer / focus state when the effect re-runs (e.g. after an option change).\n    let hovered = root.matches(\":hover\");\n    let focused = isFocusWithin(root);\n    let offscreen = false;\n\n    const pxPerMs = Math.max(speed, 1) / 1000;\n\n    // Read where the track currently is, as a fraction of a group (0 → 1 = one group to the left).\n    const readPosition = () => {\n      if (!animation || !width) return positionRef.current;\n      const duration = width / pxPerMs;\n      const progress = ((Number(animation.currentTime) || 0) % duration) / duration;\n      return direction === \"left\" ? progress : 1 - progress;\n    };\n\n    const easeRate = (target: number) => {\n      cancelAnimationFrame(rateFrame);\n      const step = () => {\n        if (!animation) return;\n        const next = animation.playbackRate + (target - animation.playbackRate) * 0.12;\n        const done = Math.abs(next - target) < 0.01;\n        animation.playbackRate = done ? target : next;\n        if (!done) rateFrame = requestAnimationFrame(step);\n      };\n      rateFrame = requestAnimationFrame(step);\n    };\n\n    const sync = () => {\n      if (!animation) return;\n      if (userPaused || offscreen || document.hidden) {\n        cancelAnimationFrame(rateFrame);\n        animation.pause();\n        return;\n      }\n      if (animation.playState !== \"running\") animation.play();\n      easeRate((pauseOnHover && hovered) || focused ? 0 : 1);\n    };\n\n    const build = () => {\n      // Sub-pixel width in layout px: divide out any ancestor scale / zoom (offsetWidth alone is rounded).\n      const rootRect = root.getBoundingClientRect().width;\n      const scale = rootRect && root.offsetWidth ? rootRect / root.offsetWidth : 1;\n      const groupWidth = group.getBoundingClientRect().width / scale;\n      if (!groupWidth) return;\n      // Enough copies that the visible area is always covered while the track moves one group.\n      const needed = Math.max(2, Math.ceil(root.clientWidth / groupWidth) + 1);\n      setCopies((current) => (current === needed ? current : needed));\n      if (animation && Math.abs(groupWidth - width) < 0.5) return;\n\n      const position = readPosition();\n      const rate = animation?.playbackRate ?? 1;\n      animation?.cancel();\n      width = groupWidth;\n      const duration = width / pxPerMs;\n      const frames =\n        direction === \"left\"\n          ? [{ transform: \"translate3d(0,0,0)\" }, { transform: `translate3d(${-width}px,0,0)` }]\n          : [{ transform: `translate3d(${-width}px,0,0)` }, { transform: \"translate3d(0,0,0)\" }];\n      animation = track.animate(frames, { duration, iterations: Infinity, easing: \"linear\" });\n      animation.currentTime = (direction === \"left\" ? position : 1 - position) * duration;\n      animation.playbackRate = rate;\n      sync();\n    };\n\n    const resizeObserver = new ResizeObserver(build);\n    resizeObserver.observe(root);\n    resizeObserver.observe(group);\n\n    const intersectionObserver = new IntersectionObserver(([entry]) => {\n      offscreen = !entry.isIntersecting;\n      sync();\n    });\n    intersectionObserver.observe(root);\n\n    const onEnter = (event: PointerEvent) => {\n      if (event.pointerType !== \"mouse\") return;\n      hovered = true;\n      sync();\n    };\n    const onLeave = () => {\n      hovered = false;\n      sync();\n    };\n    // Keyboard users tabbing into links inside the marquee get a still target.\n    const onFocusIn = () => {\n      focused = isFocusWithin(root);\n      sync();\n    };\n    const onFocusOut = (event: FocusEvent) => {\n      if (root.contains(event.relatedTarget as Node | null)) return;\n      focused = false;\n      sync();\n    };\n\n    root.addEventListener(\"pointerenter\", onEnter);\n    root.addEventListener(\"pointerleave\", onLeave);\n    root.addEventListener(\"focusin\", onFocusIn);\n    root.addEventListener(\"focusout\", onFocusOut);\n    document.addEventListener(\"visibilitychange\", sync);\n\n    return () => {\n      positionRef.current = readPosition();\n      cancelAnimationFrame(rateFrame);\n      resizeObserver.disconnect();\n      intersectionObserver.disconnect();\n      root.removeEventListener(\"pointerenter\", onEnter);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"focusin\", onFocusIn);\n      root.removeEventListener(\"focusout\", onFocusOut);\n      document.removeEventListener(\"visibilitychange\", sync);\n      animation?.cancel();\n    };\n  }, [reduceMotion, speed, direction, pauseOnHover, userPaused]);\n\n  const items = React.Children.toArray(children);\n  const renderGroup = (copy: number) => (\n    <ul\n      key={copy}\n      ref={copy === 0 ? groupRef : undefined}\n      // Copies exist only to fill the loop: hidden from assistive tech and removed from the tab order.\n      aria-hidden={copy > 0 ? true : undefined}\n      inert={copy > 0 ? true : undefined}\n      className=\"m-0 flex shrink-0 list-none items-center p-0\"\n      style={{ gap, paddingRight: gap }}\n    >\n      {items.map((item, i) => (\n        <li key={i} className=\"shrink-0\">\n          {item}\n        </li>\n      ))}\n    </ul>\n  );\n\n  if (reduceMotion) {\n    // Static alternative: one copy in a horizontally scrollable, keyboard-focusable region.\n    return (\n      <div\n        tabIndex={0}\n        className={`overflow-x-auto focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 ${className}`}\n        style={style}\n        {...props}\n      >\n        {renderGroup(0)}\n      </div>\n    );\n  }\n\n  return (\n    <div ref={rootRef} className={`relative ${className}`} style={style} {...props}>\n      <div\n        className=\"overflow-hidden\"\n        style={{ maskImage: fade ? FADE_MASK : undefined, WebkitMaskImage: fade ? FADE_MASK : undefined }}\n      >\n        <div ref={trackRef} className=\"flex w-max will-change-transform\">\n          {Array.from({ length: copies }, (_, copy) => renderGroup(copy))}\n        </div>\n      </div>\n      {controls && (\n        <button\n          type=\"button\"\n          data-marquee-control=\"\"\n          onClick={() => setUserPaused((paused) => !paused)}\n          aria-pressed={userPaused}\n          aria-label={userPaused ? \"Play marquee\" : \"Pause marquee\"}\n          className=\"absolute right-0 top-1/2 inline-flex h-7 -translate-y-1/2 items-center gap-1.5 rounded-full border border-zinc-900/10 bg-white px-2.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-600 shadow-[0_1px_2px_rgba(0,0,0,0.06)] transition-colors hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-50\"\n        >\n          <svg viewBox=\"0 0 10 10\" className=\"h-2 w-2\" aria-hidden=\"true\">\n            {userPaused ? (\n              <path d=\"M2 1l7 4-7 4z\" fill=\"currentColor\" />\n            ) : (\n              <path d=\"M2 1h2v8H2zM6 1h2v8H6z\" fill=\"currentColor\" />\n            )}\n          </svg>\n          {userPaused ? \"Play\" : \"Pause\"}\n        </button>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/infinite-marquee"
}
