{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"velocity-marquee","type":"registry:component","title":"Velocity Marquee","description":"A marquee that speeds up, leans and turns around with your scroll.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/velocity-marquee/velocity-marquee.tsx","type":"registry:component","target":"components/dimaac/velocity-marquee.tsx","content":"\"use client\";\n\nimport { useLayoutEffect, useEffect, useRef, useState } from \"react\";\nimport { gsap } from \"gsap\";\nimport { useGSAP } from \"@gsap/react\";\n\n// An endless line of big words and small pictures that drifts on its own. Scroll the page and it\n// speeds up and leans into the movement; scroll back up and it turns around. With more than one\n// row, every row runs against its neighbour.\n//\n// How the loop stays seamless: one \"set\" is the whole list of items. We measure it, then render\n// as many copies as it takes to fill the box (plus one spare). Sliding the track by exactly one\n// set's width lands on a picture identical to where it started, so we wrap with a modulo and\n// nobody sees the jump. Every item carries its gap as padding, so a set's width includes the\n// gap after its last item.\n\nexport type VelocityMarqueeTextItem = {\n  /** A word or phrase. */\n  text: string;\n};\n\nexport type VelocityMarqueeImageItem = {\n  /** The image address. */\n  src: string;\n  /** Alt text. Leave it out for a purely decorative image. */\n  alt?: string;\n};\n\nexport type VelocityMarqueeItem = VelocityMarqueeTextItem | VelocityMarqueeImageItem;\n\nexport type VelocityMarqueeProps = {\n  /** What the rows are made of, in order: words and images. */\n  items: VelocityMarqueeItem[];\n  /** How fast it drifts when nobody scrolls. 1 is about 90 pixels a second. */\n  speed?: number;\n  /** How much scrolling speeds it up. 0 ignores the scroll speed, 4 is a strong push. */\n  velocityBoost?: number;\n  /** The most it leans, in degrees, when the page scrolls fast. 0 never leans. */\n  skew?: number;\n  /** Which way the first row flows when the visitor scrolls down. Scrolling up flips it. */\n  direction?: \"left\" | \"right\";\n  /** Space between the items, in pixels. */\n  gap?: number;\n  /** How many rows to stack, one to three. They flow in alternating directions. */\n  rows?: number;\n  /** Draw the words of every second row as outlines. */\n  outlineAlternate?: boolean;\n  /** The element that scrolls, or a selector for it. Leave it out to follow the window. */\n  scroller?: HTMLElement | string | null;\n  /** Extra classes for the outer box. */\n  className?: string;\n  /** Classes for every item: the type size and weight live here. Images are sized in em, so they follow the text. */\n  itemClassName?: string;\n  /** Classes for the image items. */\n  imageClassName?: string;\n};\n\n// useLayoutEffect warns during server rendering, so fall back to useEffect there\nconst useIsomorphicLayoutEffect = typeof window !== \"undefined\" ? useLayoutEffect : useEffect;\n\nfunction resolveScroller(scroller: VelocityMarqueeProps[\"scroller\"]): HTMLElement | null {\n  if (!scroller) return null;\n  if (typeof scroller === \"string\") return document.querySelector<HTMLElement>(scroller);\n  return scroller;\n}\n\n/** Row 1 starts a few items further along than row 0, so the rows never read the same line. */\nfunction rotate<T>(list: T[], by: number): T[] {\n  if (list.length === 0) return list;\n  const shift = ((by % list.length) + list.length) % list.length;\n  return [...list.slice(shift), ...list.slice(0, shift)];\n}\n\nexport function VelocityMarquee({\n  items,\n  speed = 1,\n  velocityBoost = 4,\n  skew = 8,\n  direction = \"left\",\n  gap = 48,\n  rows = 2,\n  outlineAlternate = true,\n  scroller = null,\n  className = \"\",\n  itemClassName = \"text-[clamp(64px,13vw,200px)] font-medium leading-[1.15] tracking-[-0.04em]\",\n  imageClassName = \"rounded-full\",\n}: VelocityMarqueeProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const stackRef = useRef<HTMLDivElement>(null);\n  const rowCount = Math.min(3, Math.max(1, Math.round(rows)));\n  const [copies, setCopies] = useState(2);\n  // The width of one set per row, in pixels. Written by the measuring effect, read every frame.\n  const setWidths = useRef<number[]>([]);\n\n  // These change from the playground without rebuilding anything: the loop reads them each frame\n  const live = useRef({ speed, velocityBoost, skew, direction });\n  live.current = { speed, velocityBoost, skew, direction };\n\n  // Measure one set per row and work out how many copies fill the box\n  useIsomorphicLayoutEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const sets = Array.from(root.querySelectorAll<HTMLElement>(\"[data-first-set]\"));\n    const widths = sets.map(() => 0);\n\n    // The observer's contentRect is the layout size with its fraction (offsetWidth would round,\n    // and getBoundingClientRect would include the skew), so the wrap lands exactly on the repeat\n    const observer = new ResizeObserver((entries) => {\n      entries.forEach((entry) => {\n        const row = sets.indexOf(entry.target as HTMLElement);\n        if (row !== -1) widths[row] = entry.contentRect.width;\n      });\n      setWidths.current = [...widths];\n      const boxWidth = root.clientWidth;\n      const needed = widths.reduce((most, width) => (width > 0 ? Math.max(most, Math.ceil(boxWidth / width) + 1) : most), 2);\n      setCopies((current) => (current === needed ? current : needed));\n    });\n    // The box resizing, fonts loading and a changed gap all change a set's width\n    observer.observe(root);\n    sets.forEach((set) => observer.observe(set));\n    return () => observer.disconnect();\n  }, [rowCount]);\n\n  useGSAP(\n    () => {\n      const root = rootRef.current;\n      const stack = stackRef.current;\n      if (!root || !stack) return;\n      const tracks = Array.from(root.querySelectorAll<HTMLElement>(\"[data-track]\"));\n      const scrollElement = resolveScroller(scroller);\n      const readScroll = () => (scrollElement ? scrollElement.scrollTop : window.scrollY);\n\n      // Reduced motion: the CSS already lays the words out as a still, wrapping row. We add nothing.\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const slideTo = tracks.map((track) => gsap.quickSetter(track, \"x\", \"px\"));\n        const lean = gsap.quickSetter(stack, \"skewX\", \"deg\");\n        const offsets = tracks.map(() => 0);\n\n        let lastScroll = readScroll();\n        let velocity = 0; // pixels per second, smoothed. Positive means scrolling down.\n        let scrollSign = 1; // the last direction the visitor scrolled in\n        let flip = 1; // eases from 1 to -1 and back, so a turn-around slows down first\n        let visible = true;\n\n        const tick = (_time: number, deltaMs: number) => {\n          // A long frame (a background tab, a debugger) would fling the rows, so cap it\n          const dt = Math.min(deltaMs, 50) / 1000;\n          if (dt <= 0) return;\n\n          const scrollNow = readScroll();\n          const rawVelocity = (scrollNow - lastScroll) / dt;\n          lastScroll = scrollNow;\n          if (!visible) return;\n\n          // Wheel ticks arrive in bursts, so smooth them: it speeds up fast and settles slowly\n          velocity += (rawVelocity - velocity) * (1 - Math.exp(-dt * 7));\n          if (Math.abs(velocity) > 30) scrollSign = Math.sign(velocity);\n          flip += (scrollSign - flip) * (1 - Math.exp(-dt * 5));\n\n          const { speed: baseSpeed, velocityBoost: boost, skew: maxSkew, direction: baseDirection } = live.current;\n          const push = 1 + boost * Math.min(Math.abs(velocity) / 1500, 2);\n          const pixelsPerSecond = baseSpeed * 90 * push * flip;\n          const firstRowSign = baseDirection === \"left\" ? 1 : -1;\n\n          tracks.forEach((_, row) => {\n            const width = setWidths.current[row] ?? 0;\n            if (width <= 0) return;\n            const rowSign = row % 2 === 0 ? firstRowSign : -firstRowSign;\n            offsets[row] = (offsets[row]! + rowSign * pixelsPerSecond * dt) % width;\n            // Always between -width and 0, so a copy is on screen before the previous one leaves\n            slideTo[row]!(-(((offsets[row]! % width) + width) % width));\n          });\n\n          lean(firstRowSign * Math.max(-1, Math.min(1, velocity / 1500)) * maxSkew);\n        };\n        gsap.ticker.add(tick);\n\n        // No work while the marquee is off screen\n        const visibility = new IntersectionObserver(([entry]) => {\n          visible = entry?.isIntersecting ?? true;\n        });\n        visibility.observe(root);\n\n        return () => {\n          gsap.ticker.remove(tick);\n          visibility.disconnect();\n          gsap.set([...tracks, stack], { clearProps: \"transform\" });\n        };\n      });\n\n      return () => mm.revert();\n    },\n    { scope: rootRef, dependencies: [rowCount, scroller], revertOnUpdate: true },\n  );\n\n  const renderItem = (item: VelocityMarqueeItem, index: number, outline: boolean) => (\n    <div key={index} className={`flex shrink-0 items-center ${itemClassName}`} style={{ paddingRight: gap }}>\n      {\"src\" in item ? (\n        <img src={item.src} alt={item.alt ?? \"\"} draggable={false} decoding=\"async\" className={`block h-[0.8em] w-[1.4em] object-cover ${imageClassName}`} />\n      ) : (\n        <span className=\"whitespace-nowrap\" style={outline ? { WebkitTextFillColor: \"transparent\", WebkitTextStroke: \"0.02em currentColor\", letterSpacing: \"-0.01em\" } : undefined}>\n          {item.text}\n        </span>\n      )}\n    </div>\n  );\n\n  return (\n    <div ref={rootRef} className={`relative w-full overflow-hidden ${className}`}>\n      <div ref={stackRef} className=\"flex flex-col will-change-transform\">\n        {Array.from({ length: rowCount }, (_, row) => {\n          const rowItems = rotate(items, row * Math.ceil(items.length / 3));\n          const outline = outlineAlternate && row % 2 === 1;\n          return (\n            // Rows after the first say the same thing again, so screen readers skip them\n            <div\n              key={row}\n              data-track=\"\"\n              aria-hidden={row > 0 ? true : undefined}\n              className={`flex w-max will-change-transform motion-reduce:w-full motion-reduce:flex-wrap motion-reduce:justify-center ${row > 0 ? \"motion-reduce:hidden\" : \"\"}`}\n            >\n              {Array.from({ length: copies }, (_, copy) => (\n                <div\n                  key={copy}\n                  data-first-set={copy === 0 ? \"\" : undefined}\n                  aria-hidden={copy > 0 ? true : undefined}\n                  className={`flex shrink-0 items-center motion-reduce:w-full motion-reduce:shrink motion-reduce:flex-wrap motion-reduce:justify-center ${copy > 0 ? \"motion-reduce:hidden\" : \"\"}`}\n                >\n                  {rowItems.map((item, index) => renderItem(item, index, outline))}\n                </div>\n              ))}\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n"}],"categories":["text","gsap","marquee","scroll","velocity","typography"],"docs":"import { VelocityMarquee } from \"@/components/dimaac/velocity-marquee\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/velocity-marquee","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/velocity-marquee","credits":[{"by":"West Kast","url":"https://www.lummi.ai/photo/fashion-portrait-with-orange-visor-damql","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Dona Mara","url":"https://www.lummi.ai/photo/elegant-perfume-bottle-j5t5p","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"rena","url":"https://www.lummi.ai/photo/modern-architectural-design-aozka","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Pablo Stanley","url":"https://www.lummi.ai/photo/desert-dunes-and-mountain-range-bfczd","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"marisol","url":"https://www.lummi.ai/photo/glossy-pink-sphere-45si5","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Amino","url":"https://www.lummi.ai/photo/vivid-portrait-on-pink-oxbow","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"SHIHO","url":"https://www.lummi.ai/photo/vibrant-cylindrical-arrangement-pj9pp","what":"Photo","source":"Lummi","licence":"Lummi License"},{"by":"Viri Gutiérrez","url":"https://www.lummi.ai/photo/contemplative-silhouette-against-modern-minimalist-architecture-1-haa","what":"Photo","source":"Lummi","licence":"Lummi License"}]}}