Ticker
A Spotify-style ticker for long text. The component measures both its container and its content; when the text overflows, it scrolls back-and-forth at a configurable speed with gradient fade masks on the edges. When it fits, the component does nothing; no animation, no mask, no layout cost.
import { Ticker } from "@togetheragency/ui/ticker";
export function NewsTicker() {
return (
<div className="flex items-center gap-3 rounded-lg bg-neutral-900 px-3 py-2.5 text-white">
<span className="shrink-0 rounded border border-white/30 px-2.5 py-1 text-sm font-medium">
News
</span>
<Ticker.Root
className="flex-1"
startDelay={2000}
endDelay={2000}
scrollSpeed={40}
>
<Ticker.Content>
Health Closes Pre-Seed Funding to Automate Healthcare Compliance and
Risk Management
</Ticker.Content>
</Ticker.Root>
</div>
);
}Import
import { Ticker } from "@togetheragency/ui/ticker";The component is exported as a compound object; both sub-components are
properties of Ticker:
Ticker.Root;
Ticker.Content;Composition
Ticker is a two-part component. Root measures and animates;
Content renders the actual text.
<Ticker.Root>
<Ticker.Content>Some long text that may overflow…</Ticker.Content>
</Ticker.Root>Ticker.Rootrenders a<div>withoverflow: hidden; white-space: nowrap. Watches its width and the inner content’s width via aResizeObserver; when content exceeds container, it kicks off the scroll loop.Ticker.Contentrenders a<span>that receives a string child. The string is required (not arbitrary JSX) so the component can detect content changes and reset the animation cleanly.
The animation loop drives transform and the CSS mask-image via direct
DOM writes inside requestAnimationFrame — no React state updates per
frame. The only React state is the overflow flag, which only flips on
resize or text change. Even on long lists of tickers, this stays cheap.
Usage
Basic usage
Drop the component into any container that has a constrained width. When the text fits, nothing animates.
<Ticker.Root className="flex-1">
<Ticker.Content>
Some long string that may or may not overflow its container.
</Ticker.Content>
</Ticker.Root>Short text (no scrolling)
If the content fits, the component stays static — no mask, no transform, no animation. This is the same component, just with shorter text.
<Ticker.Root>
<Ticker.Content>Short text that fits</Ticker.Content>
</Ticker.Root>Fixed container width
Pass containerWidth (in pixels) to pin the track to an exact width
regardless of parent layout — useful inside flex parents that would
otherwise let the child grow.
<Ticker.Root
containerWidth={200}
className="rounded bg-neutral-100 px-2 py-1"
startDelay={1500}
scrollSpeed={30}
>
<Ticker.Content>
This is a much longer text that will definitely overflow the 200px container
</Ticker.Content>
</Ticker.Root>Tuning the speed
scrollSpeed is in pixels per second. Same text, three tempos.
Slow (25px/s)
Medium (50px/s)
Fast (100px/s)
<Ticker.Root scrollSpeed={25}>…</Ticker.Root>
<Ticker.Root scrollSpeed={50}>…</Ticker.Root>
<Ticker.Root scrollSpeed={100}>…</Ticker.Root>Edge fades
fadeWidth controls the gradient mask on each edge — a narrow value gives
a subtle hint of clipping, a wide value reads as a deliberate dramatic
effect.
Narrow fade (12px)
Wide fade (48px)
<Ticker.Root fadeWidth={12}>…</Ticker.Root>
<Ticker.Root fadeWidth={48}>…</Ticker.Root>Delays
startDelay and endDelay (in milliseconds) control how long the
component pauses before starting and at the end of each pass. Defaults are
2000ms for both — feel free to lengthen for ambient tickers or shorten
for active feeds.
<Ticker.Root startDelay={3000} endDelay={1500}>
<Ticker.Content>…</Ticker.Content>
</Ticker.Root>Dynamic text
Updating the children of Ticker.Content cleanly resets the
animation. The component re-measures and decides whether to scroll based
on the new content’s width.
const [text, setText] = useState("Original text");
<Ticker.Root>
<Ticker.Content>{text}</Ticker.Content>
</Ticker.Root>;Examples
News ticker
Inline label, scrolling headline, trailing icon. Only the headline runs — the surrounding chrome stays put.
<div className="flex items-center gap-3 rounded-lg bg-neutral-900 px-3 py-2.5 text-white">
<span className="shrink-0 rounded border border-white/30 px-2.5 py-1 text-sm font-medium">
News
</span>
<Ticker.Root className="flex-1" startDelay={2000} scrollSpeed={40}>
<Ticker.Content>
Health Closes Pre-Seed Funding to Automate Healthcare Compliance and Risk
Management
</Ticker.Content>
</Ticker.Root>
<ArrowRight className="size-5 shrink-0 opacity-60" />
</div>Player row
Two stacked scrollers with independent delays — title runs first, artist
follows. Each Root has its own animation loop and measurements.
<div className="flex items-center gap-4 rounded-xl bg-emerald-900 p-4">
<div className="size-14 shrink-0 rounded-md bg-emerald-700" />
<div className="min-w-0 flex-1 space-y-1">
<Ticker.Root
className="font-medium text-white"
startDelay={3000}
endDelay={2000}
scrollSpeed={35}
>
<Ticker.Content>
Bohemian Rhapsody - 2011 Remaster (Super Deluxe Edition)
</Ticker.Content>
</Ticker.Root>
<Ticker.Root
className="text-sm text-emerald-200"
startDelay={3500}
scrollSpeed={30}
>
<Ticker.Content>
Queen • A Night at the Opera (Deluxe Remastered Version)
</Ticker.Content>
</Ticker.Root>
</div>
</div>Notification banner
A pulsing status dot paired with a scrolling message — the mask hides clipping at both edges so the strip reads as live.
<div className="flex items-center gap-3 rounded-lg bg-blue-600 px-4 py-3">
<span className="relative flex size-2 shrink-0">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-white opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-white" />
</span>
<Ticker.Root className="flex-1 font-medium text-white" startDelay={2500} scrollSpeed={45}>
<Ticker.Content>
System update available: Version 2.4.1 includes performance improvements
and bug fixes. Click here to learn more.
</Ticker.Content>
</Ticker.Root>
</div>API Reference
Ticker.Root
Renders a <div> with relative overflow-hidden whitespace-nowrap.
Measures its width and the inner content’s scrollWidth, then drives a
back-and-forth scroll animation when content overflows. Extends all native
div props.
Built-in attributes: aria-live="polite".
Data attributes:
data-slot="ticker-root",
data-state="idle" | "scrolling",
data-overflowing (empty string when content overflows, otherwise absent).
Ticker.Content
Renders an inline-block <span> that holds the text. The component
registers itself with the root via context — there’s only one
Ticker.Content per root.
Data attributes: data-slot="ticker-content".
Accessibility
Ticker.Rootsetsaria-live="polite"so screen readers announce content updates without interrupting.- Because animation runs on
transformonly (no text recreation), screen readers see a stable string regardless of scroll state. - The component intentionally does not animate when content fits — the
mask is removed,
transformresets to0, and there’s nowill-changehint on the inner span. Static content costs nothing.
Exported types
import type {
TickerContentProps,
TickerRootProps,
} from "@togetheragency/ui/ticker";