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.
Scroll inside this box
"use client"
import * as React from "react"
import { ScrollVelocity } from "@/components/ballmac/scroll-velocity"
export default function ScrollVelocityDemo() {
const scroller = React.useRef<HTMLDivElement>(null)
return (
<div className="relative h-80 w-full max-w-2xl overflow-hidden rounded-2xl border bg-card">
<div ref={scroller} className="h-full overflow-y-auto" tabIndex={0} role="region" aria-label="Scroll here to speed up the rows">
<div className="h-[200%]">
<div className="sticky top-0 grid h-80 content-center gap-2">
<ScrollVelocity scrollContainer={scroller} baseVelocity={40} direction="left" className="text-5xl font-semibold tracking-tight text-foreground">
<span>Design</span>
<span aria-hidden="true" className="text-muted-foreground">✦</span>
<span>Build</span>
<span aria-hidden="true" className="text-muted-foreground">✦</span>
</ScrollVelocity>
<ScrollVelocity scrollContainer={scroller} baseVelocity={40} direction="right" className="text-5xl font-semibold tracking-tight text-muted-foreground">
<span>Ship</span>
<span aria-hidden="true">✦</span>
<span>Repeat</span>
<span aria-hidden="true">✦</span>
</ScrollVelocity>
<p className="mt-3 text-center text-xs text-muted-foreground">Scroll inside this box</p>
</div>
</div>
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/scroll-velocityInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/motion-presets @ballmac/i18nCopy the source into your project.
components/ballmac/scroll-velocity.tsx// Ballmac UI: Scroll Velocity. https://ui.ballmac.com/components/scroll-velocity "use client" import * as React from "react" import { motion, useAnimationFrame, useMotionValue, useScroll, useSpring, useTransform, useVelocity } from "motion/react" import { cn } from "@/lib/utils" import { useReducedMotionSafe } from "@/lib/ballmac/motion" import { useMessages } from "@/lib/ballmac/i18n" type ScrollVelocityProps = Omit<React.ComponentProps<"div">, "children"> & { /** What runs along the row: words, logos, anything. It is repeated to fill the width. */ children: React.ReactNode /** Pixels per second when the page is still. */ baseVelocity?: number /** Which way the row drifts when still. Scrolling down pushes it the way it already goes; scrolling up reverses it. */ direction?: "left" | "right" /** How strongly scrolling speeds the row up. 0 turns it off. */ sensitivity?: number /** A scrollable element to watch instead of the page. Pass a ref to it. */ scrollContainer?: React.RefObject<HTMLElement | null> /** Gap between repeats, as a CSS length. */ gap?: string } const wrap = (min: number, max: number, v: number) => { const range = max - min return ((((v - min) % range) + range) % range) + min } /** A row that drifts sideways and speeds up, or reverses, with how fast you scroll. */ function ScrollVelocity({ children, baseVelocity = 60, direction = "left", sensitivity = 1, scrollContainer, gap = "2rem", className, ...props }: ScrollVelocityProps) { const msg = useMessages() const reduce = useReducedMotionSafe() const rootRef = React.useRef<HTMLDivElement>(null) const copyRef = React.useRef<HTMLDivElement>(null) const [copies, setCopies] = React.useState(4) const [copyWidth, setCopyWidth] = React.useState(0) const offset = useMotionValue(0) const { scrollY } = useScroll(scrollContainer ? { container: scrollContainer } : undefined) const velocity = useVelocity(scrollY) const smooth = useSpring(velocity, { damping: 50, stiffness: 400 }) const factor = useTransform(smooth, [0, 1000], [0, 5 * sensitivity], { clamp: false }) const heading = React.useRef(direction === "left" ? -1 : 1) // Measure one copy, then repeat it enough times to cover the row plus one for the wrap. React.useLayoutEffect(() => { const root = rootRef.current const copy = copyRef.current if (!root || !copy) return const measure = () => { const w = copy.offsetWidth setCopyWidth(w) setCopies(w > 0 ? Math.ceil(root.offsetWidth / w) + 2 : 4) } measure() const ro = new ResizeObserver(measure) ro.observe(root) ro.observe(copy) return () => ro.disconnect() }, [children, gap]) useAnimationFrame((_, delta) => { if (reduce || !copyWidth) return const base = direction === "left" ? -1 : 1 const f = factor.get() if (f < -0.01) heading.current = -base else if (f > 0.01) heading.current = base let move = heading.current * baseVelocity * (delta / 1000) move += heading.current * Math.abs(move) * Math.abs(f) offset.set(wrap(-copyWidth, 0, offset.get() + move)) }) const x = useTransform(offset, (v) => `${v}px`) if (reduce) { return ( <div data-slot="scroll-velocity" // With reduced motion the row is a plain scroll area, so it must be reachable by keyboard. tabIndex={0} role="group" aria-label={msg("scroll-velocity.label", "Scrolling content")} className={cn("overflow-x-auto outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className)} {...props} > <div className="flex w-max items-center" style={{ gap }}> {children} </div> </div> ) } return ( <div ref={rootRef} data-slot="scroll-velocity" className={cn("relative overflow-hidden", className)} {...props}> <motion.div className="flex w-max items-center will-change-transform" style={{ x, gap }}> {Array.from({ length: copies }, (_, i) => ( <div key={i} ref={i === 0 ? copyRef : undefined} aria-hidden={i > 0 ? true : undefined} // The repeats are for looks only, so they are removed from tab order and screen readers. {...(i > 0 ? { inert: true } : {})} className="flex shrink-0 items-center" style={{ gap, paddingInlineEnd: gap }} > {children} </div> ))} </motion.div> </div> ) } export { ScrollVelocity, type ScrollVelocityProps }Update the import paths to match your project setup.
Usage
import { ScrollVelocity } from "@/components/ballmac/scroll-velocity"The full example is in the Code tab above.
Examples
Logo row
"use client"
import * as React from "react"
import { ScrollVelocity } from "@/components/ballmac/scroll-velocity"
const names = ["Northwind", "Acme", "Globex", "Initech", "Umbrella", "Hooli", "Stark", "Wayne"]
export default function ScrollVelocityLogos() {
const scroller = React.useRef<HTMLDivElement>(null)
return (
<div className="relative h-56 w-full max-w-2xl overflow-hidden rounded-2xl border bg-card">
<div ref={scroller} className="h-full overflow-y-auto" tabIndex={0} role="region" aria-label="Scroll here to speed up the logos">
<div className="h-[220%]">
<div className="sticky top-0 grid h-56 content-center">
<ScrollVelocity scrollContainer={scroller} baseVelocity={55} gap="3rem" sensitivity={1.4}>
{names.map((n) => (
<span key={n} className="flex items-center gap-2 text-lg font-semibold text-muted-foreground">
<span aria-hidden="true" className="size-6 rounded-md bg-muted" />
{n}
</span>
))}
</ScrollVelocity>
</div>
</div>
</div>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*What runs along the row: words, logos, anything. It is repeated to fill the width. | React.ReactNode | — |
baseVelocityPixels per second when the page is still. | number | 60 |
directionWhich way the row drifts when still. Scrolling down pushes it the way it already goes; scrolling up reverses it. | "left" | "right" | "left" |
sensitivityHow strongly scrolling speeds the row up. 0 turns it off. | number | 1 |
scrollContainerA scrollable element to watch instead of the page. Pass a ref to it. | React.RefObject<HTMLElement | null> | — |
gapGap between repeats, as a CSS length. | string | "2rem" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | Only the first copy is read; repeats are hidden and not focusable |
| Reduced motion | A static, horizontally scrollable row |
Use with AI
<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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Scroll Velocity (@ballmac/scroll-velocity) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Big typographic dividers between sections
- Logo and keyword strips that react to scrolling
Not for
- A steady marquee that ignores scroll (marquee)
Registry JSON: https://ui.ballmac.com/r/scroll-velocity.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
Pairs well with
Marquee
An endless horizontal or vertical scroller for logo rows and testimonials. Speed is in pixels per second, it pauses on hover and focus, and it fills any width.
Text Animate
Reveals text by character, word or line with eight entrances (fade, blur, slide, scale, rotate), staggered and triggered on scroll, with the full text kept for screen readers.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.