Overview
A ticker that reacts to you. Scrolling the page speeds it up, and scrolling back up turns it around. You can drag it to scrub and flick it to send it coasting, and it leans slightly with its speed. Hovering eases it down to a crawl and dims the items you're not pointing at, and the pause button eases it to a stop. Comes in three sizes, can outline every other item, and fills wide screens with as many copies as it needs. Reduced motion shows a still, wrapping list.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { Fragment, useEffect, useRef, useState } from "react";import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";import { AnimatePresence, animate, motion, useAnimationFrame, useMotionValue, useReducedMotion, useScroll, useSpring, useTransform, useVelocity,} from "motion/react";import type { AnimationPlaybackControls } from "motion/react";import { cn } from "@/lib/utils"; export type KineticMarqueeProps = { items: ReactNode[]; /** Accessible name for the marquee. */ label?: string; /** Pixels per second. Overrides `duration`. */ speed?: number; /** Seconds for one full loop, used when `speed` isn't set. */ duration?: number; direction?: "left" | "right"; size?: "sm" | "md" | "lg"; /** Rendered between items. Defaults to a small dot. */ separator?: ReactNode; /** Render every other item as outlined text. Best with `size="lg"`. */ outline?: boolean; /** Page scroll speeds the marquee up and flips it when scrolling back. */ scrollVelocity?: boolean; /** Drag to scrub, with a flick that coasts. */ draggable?: boolean; /** Show the pause button. */ controls?: boolean; className?: string;}; const SIZES = { sm: { text: "text-[14px] font-medium tracking-tight", gap: "gap-6 pr-6 sm:gap-8 sm:pr-8", speed: 48 }, md: { text: "text-[22px] font-semibold tracking-tight", gap: "gap-7 pr-7 sm:gap-10 sm:pr-10", speed: 56 }, lg: { text: "font-(family-name:--font-display) text-[clamp(2.25rem,7vw,4rem)] leading-none font-semibold tracking-[-0.03em]", gap: "gap-8 pr-8 sm:gap-12 sm:pr-12", speed: 64, },} as const; const wrap = (width: number, value: number) => (width <= 0 ? 0 : ((((value % width) + width) % width) - width)); export function KineticMarquee({ items, label = "Highlights", speed, duration, direction = "left", size = "sm", separator, outline = false, scrollVelocity = true, draggable = true, controls = true, className,}: KineticMarqueeProps) { const reduce = !!useReducedMotion(); const preset = SIZES[size]; const viewportRef = useRef<HTMLDivElement>(null); const groupRef = useRef<HTMLDivElement>(null); const [groupWidth, setGroupWidth] = useState(0); const [copies, setCopies] = useState(2); const [paused, setPaused] = useState(false); const [hovered, setHovered] = useState(false); const [focused, setFocused] = useState(false); const [dragging, setDragging] = useState(false); // Measure one copy so the loop wraps exactly, and render enough copies to fill wide screens. useEffect(() => { const viewport = viewportRef.current; const group = groupRef.current; if (!viewport || !group) return; const measure = () => { const w = group.offsetWidth; setGroupWidth(w); if (w > 0) setCopies(Math.max(2, Math.ceil(viewport.offsetWidth / w) + 1)); }; measure(); const ro = new ResizeObserver(measure); ro.observe(viewport); ro.observe(group); return () => ro.disconnect(); }, [items, size]); const x = useMotionValue(0); const tx = useTransform(() => wrap(groupWidth, x.get())); // Throttle eases toward its target instead of stopping dead: hover slows, pause and focus stop. const throttle = useSpring(1, { stiffness: 90, damping: 22 }); useEffect(() => { throttle.set(paused || focused ? 0 : hovered ? 0.18 : 1); }, [paused, focused, hovered, throttle]); // Page scroll boosts the speed; scrolling back up flips direction. const { scrollY } = useScroll(); const scrollVel = useSpring(useVelocity(scrollY), { damping: 50, stiffness: 400 }); const boost = useTransform(scrollVel, [-1000, 0, 1000], [-4, 0, 4], { clamp: false }); // Skew follows how fast the text is actually moving. const xVel = useSpring(useVelocity(x), { damping: 40, stiffness: 300 }); const skewX = useTransform(xVel, [-1600, 0, 1600], [12, 0, -12]); const base = direction === "left" ? -1 : 1; const dir = useRef(base); useEffect(() => { dir.current = base; }, [base]); const coast = useRef<AnimationPlaybackControls | null>(null); const drag = useRef<{ id: number; startX: number; lastX: number; moved: boolean } | null>(null); const suppressClick = useRef(false); const pxPerSecond = speed ?? (duration && groupWidth ? groupWidth / Math.max(1, duration) : preset.speed); useAnimationFrame((_, delta) => { if (reduce || drag.current || coast.current) return; const dt = Math.min(delta, 64) / 1000; let move = dir.current * pxPerSecond * dt * throttle.get(); if (scrollVelocity) { const b = boost.get(); if (b < 0) dir.current = -base; else if (b > 0) dir.current = base; move += move * Math.abs(b); } if (move !== 0) x.set(x.get() + move); }); const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => { if (!draggable || reduce || !e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) return; coast.current?.stop(); coast.current = null; drag.current = { id: e.pointerId, startX: e.clientX, lastX: e.clientX, moved: false }; }; const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; if (!d.moved && Math.abs(e.clientX - d.startX) > 4) { d.moved = true; setDragging(true); try { e.currentTarget.setPointerCapture(e.pointerId); } catch {} } if (!d.moved) return; x.set(x.get() + (e.clientX - d.lastX)); d.lastX = e.clientX; }; const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => { const d = drag.current; if (!d || d.id !== e.pointerId) return; drag.current = null; if (!d.moved) return; setDragging(false); suppressClick.current = true; const velocity = x.getVelocity(); // Flicking sets the drift direction too. if (Math.abs(velocity) > 200) dir.current = velocity < 0 ? -1 : 1; coast.current = animate(x, x.get(), { type: "inertia", velocity, power: 0.35, timeConstant: 420, onComplete: () => { coast.current = null; }, }); }; useEffect(() => () => coast.current?.stop(), []); if (!items.length) return null; const sep = separator ?? ( <span aria-hidden className={cn("block shrink-0 rounded-full bg-(--color-neutral)/60", size === "lg" ? "size-2.5" : "size-1.5")} /> ); const group = (copy: number) => ( <div key={copy} ref={copy === 0 ? groupRef : undefined} role={copy === 0 ? "list" : undefined} aria-hidden={copy === 0 ? undefined : true} inert={copy === 0 ? undefined : true} className={cn("flex shrink-0 items-center", preset.gap)} > {items.map((item, i) => ( <Fragment key={i}> <div role={copy === 0 ? "listitem" : undefined} data-marquee-item className={cn( "shrink-0 whitespace-nowrap transition-[opacity,filter] duration-300", preset.text, outline && i % 2 === 1 ? "text-transparent [-webkit-text-stroke:1.25px_var(--color-ink)]" : "text-(--color-ink)", )} > {item} </div> <div aria-hidden className="flex shrink-0 items-center text-(--color-neutral)"> {sep} </div> </Fragment> ))} </div> ); return ( <section aria-label={label} aria-roledescription="marquee" className={cn("relative flex items-center gap-3 border-y border-(--color-rule)", size === "lg" ? "py-7" : "py-4", className)} onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)} onPointerLeave={() => setHovered(false)} onFocus={(e) => setFocused(!!(e.target as HTMLElement).closest("[data-marquee-viewport]"))} onBlur={(e) => { const next = e.relatedTarget as HTMLElement | null; if (!next || !e.currentTarget.contains(next) || !next.closest("[data-marquee-viewport]")) setFocused(false); }} > <div ref={viewportRef} data-marquee-viewport onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} onClickCapture={(e) => { if (suppressClick.current) { suppressClick.current = false; e.preventDefault(); e.stopPropagation(); } }} className={cn( "relative min-w-0 flex-1 overflow-hidden", "[mask-image:linear-gradient(to_right,transparent,black_7%,black_93%,transparent)]", draggable && !reduce && (dragging ? "cursor-grabbing select-none" : "cursor-grab"), !reduce && "touch-pan-y", )} > {reduce ? ( // Reduced motion: a still, wrapping row with no duplicates. <div role="list" className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 px-4"> {items.map((item, i) => ( <div key={i} role="listitem" className={cn( preset.text, outline && i % 2 === 1 ? "text-transparent [-webkit-text-stroke:1.25px_var(--color-ink)]" : "text-(--color-ink)", )} > {item} </div> ))} </div> ) : ( <motion.div style={{ x: tx, skewX: dragging ? 0 : skewX }} className={cn( "flex w-max will-change-transform", // Hovering one item quietly dims the rest. "[@media(hover:hover)]:[&:has([data-marquee-item]:hover)_[data-marquee-item]:not(:hover)]:opacity-35", "[@media(hover:hover)]:[&:has([data-marquee-item]:hover)_[data-marquee-item]:not(:hover)]:blur-[1px]", )} > {Array.from({ length: copies }, (_, c) => group(c))} </motion.div> )} </div> {controls && !reduce ? ( <button type="button" aria-label={paused ? `Play ${label}` : `Pause ${label}`} aria-pressed={paused} onClick={() => setPaused((p) => !p)} className={cn( "relative mr-1 grid size-9 shrink-0 place-items-center overflow-hidden rounded-full bg-(--color-paper) text-(--color-ink-2) outline-none", "shadow-[inset_0_0_0_1px_var(--color-rule)] transition-[color,background-color,transform] duration-200", "hover:bg-(--color-paper-2) hover:text-(--color-ink) active:scale-[0.94] focus-visible:ring-2 focus-visible:ring-(--color-focus)", )} > <AnimatePresence initial={false} mode="popLayout"> <motion.svg key={paused ? "play" : "pause"} aria-hidden viewBox="0 0 16 16" className="size-3.5" fill="currentColor" initial={{ opacity: 0, scale: 0.5, filter: "blur(3px)" }} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }} exit={{ opacity: 0, scale: 0.5, filter: "blur(3px)" }} transition={{ type: "spring", bounce: 0.3, duration: 0.35 }} > {paused ? <path d="M5 3.2v9.6c0 .5.6.8 1 .5l7-4.8a.6.6 0 0 0 0-1L6 2.7c-.4-.3-1 0-1 .5Z" /> : <path d="M4 3h2.6v10H4zM9.4 3H12v10H9.4z" />} </motion.svg> </AnimatePresence> </button> ) : null} </section> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { KineticMarquee } from "@/components/ui/kinetic-marquee"; <KineticMarquee size="lg" outline label="Capabilities" items={["Product design", "Prototyping", "Motion systems"]}/> <KineticMarquee direction="right" separator="✦" items={tools} />
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| items | ReactNode[] | Yes | — | Items to loop. Strings, or links and logos. |
| label | string | No | "Highlights" | Accessible name for the marquee. |
| speed | number | No | 48 / 56 / 64 | Pixels per second. The default depends on size. |
| duration | number | No | — | Seconds per loop, used when speed isn't set. |
| direction | "left" | "right" | No | "left" | Drift direction. |
| size | "sm" | "md" | "lg" | No | "sm" | Type scale. lg uses the display font. |
| separator | ReactNode | No | — | Shown between items. Defaults to a dot. |
| outline | boolean | No | false | Outline every other item. |
| scrollVelocity | boolean | No | true | Page scroll speeds it up and flips its direction. |
| draggable | boolean | No | true | Drag to scrub, with inertia on release. |
| controls | boolean | No | true | Show the pause button. |
| className | string | No | — | Classes for the wrapper. |
Best Practices
- Use it for decorative or secondary content; nothing important should only be visible while scrolling past.
- Keep the pause button (
controls) unless the marquee is very short. Moving content needs a way to stop it. - Links inside are reachable by keyboard; focusing one pauses the marquee until focus leaves.
- Only the first copy is exposed to assistive tech, so screen readers hear each item once.