{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"logo-marquee","type":"registry:component","title":"Logo marquee","description":"A quiet strip of logos that notices you: it eases to a stop on hover and the logo you point at stays bright.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/logo-marquee.tsx","type":"registry:component","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * LOGO MARQUEE: a quiet strip of logos that notices you\n *\n *   moving   logos drift past at a steady speed, fading out at\n *            both edges; any width, any number of logos\n *   hover    the strip eases to a stop instead of halting, the\n *            logo you point at stays bright and the rest dim\n *   leave    it eases back up to speed\n *\n * The speed is in pixels per second, so a longer strip doesn't\n * move faster. With reduced motion the logos simply sit in a row.\n * ───────────────────────────────────────────────────────── */\n\nexport interface LogoMarqueeProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** The logos, each an image or an inline SVG with an accessible name. */\n  logos: React.ReactNode[];\n  /** Pixels per second. */\n  speed?: number;\n  /** Space between logos (px). */\n  gap?: number;\n  direction?: \"left\" | \"right\";\n  /** Ease to a stop on hover. */\n  pauseOnHover?: boolean;\n}\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nexport function LogoMarquee({ logos, speed = 36, gap = 56, direction = \"left\", pauseOnHover = true, className = \"\", ...props }: LogoMarqueeProps) {\n  const reduced = useReducedMotion();\n  const trackRef = React.useRef<HTMLDivElement>(null);\n  const animation = React.useRef<Animation | null>(null);\n  const tween = React.useRef(0);\n\n  // One loop is exactly one set of logos; its duration follows from the set's width and the speed.\n  React.useEffect(() => {\n    const track = trackRef.current;\n    if (!track || reduced) return;\n    const set = () => track.scrollWidth / 2;\n    const keyframes = direction === \"left\" ? [{ transform: \"translateX(0)\" }, { transform: \"translateX(-50%)\" }] : [{ transform: \"translateX(-50%)\" }, { transform: \"translateX(0)\" }];\n    const a = track.animate(keyframes, { duration: (set() / speed) * 1000, iterations: Infinity, easing: \"linear\" });\n    animation.current = a;\n    const observer = new ResizeObserver(() => a.effect?.updateTiming({ duration: (set() / speed) * 1000 }));\n    observer.observe(track);\n    return () => {\n      observer.disconnect();\n      cancelAnimationFrame(tween.current);\n      a.cancel();\n      animation.current = null;\n    };\n  }, [speed, direction, reduced, logos.length]);\n\n  // Ease the playback rate toward a target, so stopping and starting feel physical.\n  const easeTo = (target: number) => {\n    const a = animation.current;\n    if (!a) return;\n    cancelAnimationFrame(tween.current);\n    const from = a.playbackRate;\n    const start = performance.now();\n    const step = (now: number) => {\n      const t = Math.min(1, (now - start) / 650);\n      const eased = 1 - Math.pow(1 - t, 3);\n      // Never below zero, or the strip would briefly run backwards.\n      a.playbackRate = t < 1 ? Math.max(0, from + (target - from) * eased) : target;\n      if (t < 1) tween.current = requestAnimationFrame(step);\n    };\n    tween.current = requestAnimationFrame(step);\n  };\n\n  const items = (copy: number) =>\n    logos.map((logo, i) => (\n      <li\n        key={`${copy}-${i}`}\n        aria-hidden={copy > 0 || undefined}\n        className=\"flex shrink-0 items-center text-muted-foreground transition-[opacity,color,filter] duration-500 group-hover/marquee:opacity-35 hover:!opacity-100 hover:text-foreground\"\n        style={{ marginRight: gap }}\n      >\n        {logo}\n      </li>\n    ));\n\n  if (reduced) {\n    return (\n      <div className={`w-full ${className}`} {...props}>\n        <ul className=\"flex flex-wrap items-center justify-center gap-y-6\" style={{ columnGap: gap }}>\n          {logos.map((logo, i) => (\n            <li key={i} className=\"flex items-center text-muted-foreground\">\n              {logo}\n            </li>\n          ))}\n        </ul>\n      </div>\n    );\n  }\n\n  return (\n    <div\n      className={`group/marquee w-full overflow-hidden [mask-image:linear-gradient(to_right,transparent,#000_12%,#000_88%,transparent)] ${className}`}\n      onPointerEnter={() => pauseOnHover && easeTo(0)}\n      onPointerLeave={() => pauseOnHover && easeTo(1)}\n      {...props}\n    >\n      <div ref={trackRef} className=\"flex w-max will-change-transform\">\n        <ul className=\"flex shrink-0 items-center\">{items(0)}</ul>\n        <ul aria-hidden=\"true\" className=\"flex shrink-0 items-center\">\n          {items(1)}\n        </ul>\n      </div>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/logo-marquee"}