{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"logo-marquee","type":"registry:ui","title":"Logo Marquee","description":"Stops when you look at it.","dependencies":["motion"],"categories":["content"],"docs":"https://www.interior.dev/docs/logo-marquee","files":[{"path":"registry/interior/logo-marquee.tsx","type":"registry:ui","target":"components/interior/logo-marquee.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useIsomorphicLayoutEffect, useReducedMotion } from \"motion/react\";\n\nconst RAMP = 0.19;\nconst SETTLE = 0.16;\nconst MAX_COPIES = 14;\n\nexport type MarqueeDirection = \"left\" | \"right\";\n\nexport type UseLogoMarqueeOptions = {\n  speed?: number;\n  direction?: MarqueeDirection;\n  gap?: number;\n  paused?: boolean;\n};\n\nfunction fold(x: number, loop: number) {\n  const m = x % loop;\n  return m > 0 ? m - loop : m;\n}\n\nfunction clamp(x: number, min: number, max: number) {\n  return x < min ? min : x > max ? max : x;\n}\n\nexport function useLogoMarquee({\n  speed = 44,\n  direction = \"left\",\n  gap = 40,\n  paused = false,\n}: UseLogoMarqueeOptions = {}) {\n  const viewportRef = useRef<HTMLDivElement>(null);\n  const trackRef = useRef<HTMLDivElement>(null);\n  const groupRef = useRef<HTMLUListElement>(null);\n\n  const [copies, setCopies] = useState(4);\n  const [held, setHeld] = useState(false);\n  const [near, setNear] = useState(false);\n\n  const reduced = useReducedMotion() === true;\n  const stopped = held || paused;\n\n  const reducedRef = useRef(reduced);\n  reducedRef.current = reduced;\n  const movingRef = useRef(false);\n  movingRef.current = !stopped && !reduced;\n\n  const offset = useRef(0);\n  const nudge = useRef(0);\n  const rate = useRef(0);\n  const span = useRef(0);\n\n  const paint = useCallback(() => {\n    const track = trackRef.current;\n    if (!track) return;\n    const x = reducedRef.current ? 0 : offset.current - span.current;\n    track.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`;\n  }, []);\n\n  useIsomorphicLayoutEffect(() => {\n    const viewport = viewportRef.current;\n    const group = groupRef.current;\n    if (!viewport || !group) return;\n\n    const measure = () => {\n      const width = group.getBoundingClientRect().width;\n      const loop = width > 0 ? width + gap : 0;\n      const room = viewport.getBoundingClientRect().width;\n      span.current = loop;\n      offset.current = loop > 0 ? clamp(offset.current, -loop, loop) : 0;\n      paint();\n\n      const next =\n        reduced || loop <= 0\n          ? 4\n          : clamp(Math.ceil(room / loop) + 3, 4, MAX_COPIES);\n      setCopies((prev) => (prev === next ? prev : next));\n    };\n\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(viewport);\n    observer.observe(group);\n    return () => observer.disconnect();\n  }, [gap, paint, reduced]);\n\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n    if (typeof IntersectionObserver === \"undefined\") {\n      setNear(true);\n      return;\n    }\n\n    const observer = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[entries.length - 1];\n        if (entry) setNear(entry.isIntersecting);\n      },\n      { rootMargin: \"96px\" },\n    );\n    observer.observe(viewport);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    if (reduced || !near) return;\n\n    let frame = 0;\n    let last = 0;\n    const sign = direction === \"right\" ? 1 : -1;\n\n    const tick = (now: number) => {\n      frame = requestAnimationFrame(tick);\n\n      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;\n      last = now;\n\n      const loop = span.current;\n      if (loop <= 0) return;\n\n      rate.current +=\n        ((movingRef.current ? 1 : 0) - rate.current) * (1 - Math.exp(-dt / RAMP));\n\n      const pull = nudge.current * (1 - Math.exp(-dt / SETTLE));\n      nudge.current -= pull;\n\n      let x = offset.current + sign * speed * rate.current * dt + pull;\n      if (rate.current > 0.002 && Math.abs(nudge.current) < 0.25) {\n        nudge.current = 0;\n        x = fold(x, loop);\n      } else {\n        x = clamp(x, -loop, loop);\n      }\n\n      offset.current = x;\n      paint();\n    };\n\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [reduced, near, speed, direction, paint]);\n\n  useEffect(() => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n\n    const pin = () => {\n      if (reducedRef.current) return;\n      if (viewport.scrollLeft !== 0) viewport.scrollLeft = 0;\n      if (viewport.scrollTop !== 0) viewport.scrollTop = 0;\n    };\n\n    viewport.addEventListener(\"scroll\", pin, { passive: true });\n    return () => viewport.removeEventListener(\"scroll\", pin);\n  }, []);\n\n  useEffect(() => {\n    const release = () => setHeld(false);\n    window.addEventListener(\"blur\", release);\n    return () => window.removeEventListener(\"blur\", release);\n  }, []);\n\n  const reveal = useCallback((node: HTMLElement) => {\n    const viewport = viewportRef.current;\n    const loop = span.current;\n    if (!viewport || reducedRef.current || loop <= 0) return;\n    if (node === viewport) return;\n\n    const view = viewport.getBoundingClientRect();\n    const box = node.getBoundingClientRect();\n    const pad = 12;\n\n    let delta = 0;\n    if (box.left < view.left + pad) delta = view.left + pad - box.left;\n    else if (box.right > view.right - pad) delta = view.right - pad - box.right;\n    if (delta === 0) return;\n\n    const target = clamp(offset.current + nudge.current + delta, -loop, loop);\n    nudge.current = target - offset.current;\n  }, []);\n\n  const bind = {\n    onPointerEnter: (e: React.PointerEvent) => {\n      if (e.pointerType !== \"touch\") setHeld(true);\n    },\n    onPointerDown: () => setHeld(true),\n    onPointerUp: (e: React.PointerEvent) => {\n      if (e.pointerType === \"touch\") setHeld(false);\n    },\n    onPointerCancel: () => setHeld(false),\n    onPointerLeave: () => setHeld(false),\n    onFocus: (e: React.FocusEvent) => {\n      setHeld(true);\n      reveal(e.target as HTMLElement);\n    },\n    onBlur: () => setHeld(false),\n  };\n\n  return {\n    viewportRef,\n    trackRef,\n    groupRef,\n    copies,\n    paused: stopped,\n    reduced,\n    bind,\n  };\n}\n\nexport type LogoMarqueeItem = {\n  id: string;\n  label: string;\n  href?: string;\n  mark?: React.ReactNode;\n};\n\nexport type LogoMarqueeProps = {\n  items: LogoMarqueeItem[];\n  label?: string;\n  speed?: number;\n  direction?: MarqueeDirection;\n  gap?: number;\n  paused?: boolean;\n  onSelect?: (item: LogoMarqueeItem) => void;\n  className?: string;\n};\n\nconst FACE =\n  \"inline-flex h-10 shrink-0 items-center gap-2 whitespace-nowrap rounded-[9px] px-3 text-[13px] font-medium tracking-[-0.01em] text-stone-500 dark:text-stone-400\";\n\nconst HIT =\n  \"outline-none transition-colors duration-150 hover:text-stone-700 focus-visible:bg-[#4568FF]/[0.06] focus-visible:text-stone-700 focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:hover:text-stone-200 dark:focus-visible:bg-[#93B0FF]/[0.10] dark:focus-visible:text-stone-200 dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\";\n\nfunction face(item: LogoMarqueeItem) {\n  if (!item.mark) return item.label;\n  return (\n    <>\n      <span aria-hidden>{item.mark}</span>\n      <span className=\"sr-only\">{item.label}</span>\n    </>\n  );\n}\n\nexport function LogoMarquee({\n  items,\n  label = \"Logos\",\n  speed = 44,\n  direction = \"left\",\n  gap = 40,\n  paused = false,\n  onSelect,\n  className = \"\",\n}: LogoMarqueeProps) {\n  const { viewportRef, trackRef, groupRef, copies, reduced, bind } =\n    useLogoMarquee({ speed, direction, gap, paused });\n\n  const groups = reduced ? 1 : copies;\n  const live = reduced ? 0 : 1;\n\n  return (\n    <section\n      aria-label={label}\n      className={`relative isolate w-full min-w-0 max-w-full overflow-hidden rounded-[14px] border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.06),0_4px_10px_-8px_rgba(28,25,23,0.45)] dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:shadow-[0_1px_6px_rgba(0,0,0,0.45)] ${className}`}\n      {...bind}\n    >\n      <div\n        ref={viewportRef}\n\n        // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex\n        tabIndex={reduced ? 0 : undefined}\n        style={{ overflowX: reduced ? \"auto\" : \"hidden\" }}\n        className=\"overflow-y-hidden py-2 outline-none focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.10] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF]\"\n      >\n        <div\n          ref={trackRef}\n          style={{ gap, willChange: \"transform\" }}\n          className=\"flex w-max items-center\"\n        >\n          {Array.from({ length: groups }, (_, copy) => (\n            <ul\n              key={copy}\n              ref={copy === live ? groupRef : undefined}\n              aria-hidden={copy === live ? undefined : true}\n              style={{ gap }}\n              className=\"flex w-max items-center\"\n            >\n              {items.map((item) => (\n                <li key={item.id} className=\"shrink-0\">\n                  {copy !== live ? (\n                    <span className={FACE}>{item.mark ?? item.label}</span>\n                  ) : item.href ? (\n                    <a href={item.href} className={`${FACE} ${HIT}`}>\n                      {face(item)}\n                    </a>\n                  ) : onSelect ? (\n                    <button\n                      type=\"button\"\n                      onClick={() => onSelect(item)}\n                      className={`${FACE} ${HIT}`}\n                    >\n                      {face(item)}\n                    </button>\n                  ) : (\n                    <span className={FACE}>{face(item)}</span>\n                  )}\n                </li>\n              ))}\n            </ul>\n          ))}\n        </div>\n      </div>\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-y-0 left-0 w-10 bg-gradient-to-r from-white to-white/0 dark:from-stone-900 dark:to-stone-900/0\"\n      />\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-white to-white/0 dark:from-stone-900 dark:to-stone-900/0\"\n      />\n    </section>\n  );\n}\n"}]}