{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-velocity",
  "title": "Scroll Velocity",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A looping row of text or logos that drifts sideways and speeds up, or reverses, with how fast you scroll the page or any scroll container, repeating itself to fill the width.",
  "dependencies": [
    "motion@^12"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/scroll-velocity.tsx",
      "content": "// Ballmac UI: Scroll Velocity. https://ui.ballmac.com/components/scroll-velocity\n\"use client\"\n\nimport * as React from \"react\"\nimport { motion, useAnimationFrame, useMotionValue, useScroll, useSpring, useTransform, useVelocity } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useReducedMotionSafe } from \"@/lib/ballmac/motion\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype ScrollVelocityProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** What runs along the row: words, logos, anything. It is repeated to fill the width. */\n  children: React.ReactNode\n  /** Pixels per second when the page is still. */\n  baseVelocity?: number\n  /** Which way the row drifts when still. Scrolling down pushes it the way it already goes; scrolling up reverses it. */\n  direction?: \"left\" | \"right\"\n  /** How strongly scrolling speeds the row up. 0 turns it off. */\n  sensitivity?: number\n  /** A scrollable element to watch instead of the page. Pass a ref to it. */\n  scrollContainer?: React.RefObject<HTMLElement | null>\n  /** Gap between repeats, as a CSS length. */\n  gap?: string\n}\n\nconst wrap = (min: number, max: number, v: number) => {\n  const range = max - min\n  return ((((v - min) % range) + range) % range) + min\n}\n\n/** A row that drifts sideways and speeds up, or reverses, with how fast you scroll. */\nfunction ScrollVelocity({ children, baseVelocity = 60, direction = \"left\", sensitivity = 1, scrollContainer, gap = \"2rem\", className, ...props }: ScrollVelocityProps) {\n  const msg = useMessages()\n  const reduce = useReducedMotionSafe()\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const copyRef = React.useRef<HTMLDivElement>(null)\n  const [copies, setCopies] = React.useState(4)\n  const [copyWidth, setCopyWidth] = React.useState(0)\n\n  const offset = useMotionValue(0)\n  const { scrollY } = useScroll(scrollContainer ? { container: scrollContainer } : undefined)\n  const velocity = useVelocity(scrollY)\n  const smooth = useSpring(velocity, { damping: 50, stiffness: 400 })\n  const factor = useTransform(smooth, [0, 1000], [0, 5 * sensitivity], { clamp: false })\n  const heading = React.useRef(direction === \"left\" ? -1 : 1)\n\n  // Measure one copy, then repeat it enough times to cover the row plus one for the wrap.\n  React.useLayoutEffect(() => {\n    const root = rootRef.current\n    const copy = copyRef.current\n    if (!root || !copy) return\n    const measure = () => {\n      const w = copy.offsetWidth\n      setCopyWidth(w)\n      setCopies(w > 0 ? Math.ceil(root.offsetWidth / w) + 2 : 4)\n    }\n    measure()\n    const ro = new ResizeObserver(measure)\n    ro.observe(root)\n    ro.observe(copy)\n    return () => ro.disconnect()\n  }, [children, gap])\n\n  useAnimationFrame((_, delta) => {\n    if (reduce || !copyWidth) return\n    const base = direction === \"left\" ? -1 : 1\n    const f = factor.get()\n    if (f < -0.01) heading.current = -base\n    else if (f > 0.01) heading.current = base\n    let move = heading.current * baseVelocity * (delta / 1000)\n    move += heading.current * Math.abs(move) * Math.abs(f)\n    offset.set(wrap(-copyWidth, 0, offset.get() + move))\n  })\n\n  const x = useTransform(offset, (v) => `${v}px`)\n\n  if (reduce) {\n    return (\n      <div\n        data-slot=\"scroll-velocity\"\n        // With reduced motion the row is a plain scroll area, so it must be reachable by keyboard.\n        tabIndex={0}\n        role=\"group\"\n        aria-label={msg(\"scroll-velocity.label\", \"Scrolling content\")}\n        className={cn(\"overflow-x-auto outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\", className)}\n        {...props}\n      >\n        <div className=\"flex w-max items-center\" style={{ gap }}>\n          {children}\n        </div>\n      </div>\n    )\n  }\n\n  return (\n    <div ref={rootRef} data-slot=\"scroll-velocity\" className={cn(\"relative overflow-hidden\", className)} {...props}>\n      <motion.div className=\"flex w-max items-center will-change-transform\" style={{ x, gap }}>\n        {Array.from({ length: copies }, (_, i) => (\n          <div\n            key={i}\n            ref={i === 0 ? copyRef : undefined}\n            aria-hidden={i > 0 ? true : undefined}\n            // The repeats are for looks only, so they are removed from tab order and screen readers.\n            {...(i > 0 ? { inert: true } : {})}\n            className=\"flex shrink-0 items-center\"\n            style={{ gap, paddingInlineEnd: gap }}\n          >\n            {children}\n          </div>\n        ))}\n      </motion.div>\n    </div>\n  )\n}\n\nexport { ScrollVelocity, type ScrollVelocityProps }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/scroll-velocity.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "marquee",
      "scroll",
      "velocity",
      "text",
      "ticker"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "<ScrollVelocity baseVelocity direction sensitivity scrollContainer gap>content</ScrollVelocity>. Stack rows with opposite directions. Repeats are aria-hidden and inert; reduced motion shows one scrollable row.",
      "whenToUse": [
        "Big typographic dividers between sections",
        "Logo and keyword strips that react to scrolling"
      ],
      "whenNotToUse": [
        "A steady marquee that ignores scroll (marquee)"
      ],
      "composesWith": [
        "marquee",
        "text-animate"
      ],
      "a11y": [
        {
          "keys": "Screen readers",
          "action": "Only the first copy is read; repeats are hidden and not focusable"
        },
        {
          "keys": "Reduced motion",
          "action": "A static, horizontally scrollable row"
        }
      ],
      "customization": [
        "baseVelocity",
        "direction",
        "sensitivity",
        "scrollContainer",
        "gap"
      ]
    },
    "examples": [
      "scroll-velocity-demo",
      "scroll-velocity-logos"
    ],
    "url": "https://ui.ballmac.com/components/scroll-velocity"
  },
  "categories": [
    "motion"
  ],
  "type": "registry:ui"
}