COMPONENT / ticker-marquee
Ticker Marquee_
Signal band that scrolls its items endlessly, with hover, focus and button pause and a static reduced-motion state. Requires @cybr/tokens and @cybr/effects.
Install
1. Map the
@cybrnamespace, once per project. Add this inside yourcomponents.json, or into its existing"registries"object. Nocomponents.jsonyet? Runnpx shadcn@latest initfirst to create one."registries": { "@cybr": "https://cybr-ui.com/r/{name}.json" }2. Add components:
$ npx shadcn@latest add @cybr/ticker-marquee
If shadcn reports Unknown registry "@cybr", step 1 is missing from that project's components.json.
Preview
- 01 / TICKER MARQUEE
- // NEW DROP: GLITCH BUTTON
- // REV 0.1 LIVE
- // SYS:OK
- // NPX SHADCN ADD @CYBR/TICKER-MARQUEE
registry/ui/ticker-marquee.tsx
"use client";
import { useState, type ComponentProps, type CSSProperties, type ReactNode } from "react";
import { cn } from "@/lib/utils";
// Signal ticker band that scrolls its items endlessly. The track holds two
// copies (the second hidden from assistive tech) so the loop is seamless; pass
// enough items to fill the widest band. It pauses on hover, while focus is
// inside, and from the PAUSE button (WCAG 2.2.2). Under reduced motion it is
// static and the button is not shown.
export function TickerMarquee({
items,
duration = 26,
reverse = false,
pauseControl = true,
className,
style,
...props
}: Omit<ComponentProps<"div">, "children"> & {
items: ReactNode[];
duration?: number;
reverse?: boolean;
pauseControl?: boolean;
}) {
const [paused, setPaused] = useState(false);
const list = (hidden?: boolean) => (
<ul aria-hidden={hidden || undefined} className="m-0 flex shrink-0 list-none p-0">
{items.map((item, i) => (
<li key={i} className="pr-12 whitespace-nowrap">
{item}
</li>
))}
</ul>
);
return (
<div
className={cn(
"group relative flex h-12.5 items-center overflow-hidden border-y-(length:--border-base) border-ground-deep bg-signal font-mono text-sm font-bold tracking-label text-ground-deep",
className,
)}
style={
{
"--marquee-duration": `${duration}s`,
"--marquee-direction": reverse ? "reverse" : "normal",
...style,
} as CSSProperties
}
{...props}
>
{items.length > 0 ? (
<>
<div
className={cn(
"marquee flex w-max group-focus-within:[animation-play-state:paused] group-hover:[animation-play-state:paused]",
paused && "[animation-play-state:paused]",
)}
>
{list()}
{list(true)}
</div>
{pauseControl ? (
<button
type="button"
onClick={() => setPaused((p) => !p)}
className="absolute inset-y-0 right-0 z-10 cursor-pointer bg-ground-deep px-3 font-mono text-caption font-bold tracking-label text-ink focus-visible:outline-(length:--focus-width) focus-visible:outline-offset-[-4px] focus-visible:outline-ink motion-reduce:hidden"
>
{paused ? "PLAY" : "PAUSE"}
<span className="sr-only"> ticker</span>
</button>
) : null}
</>
) : null}
</div>
);
}
Repo source. The shadcn CLI rewrites import aliases to match your project.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | ReactNode[] | — | The ticker entries. Pass enough to fill the widest band, or a gap shows before the loop repeats. |
| duration | number | 26 | Seconds for one full loop. |
| reverse | boolean | false | Scroll left to right instead. |
| pauseControl | boolean | true | Show the PAUSE/PLAY button. Keep it unless the page offers another way to stop the motion. |
| ...props | Omit<ComponentProps<"div">, "children"> | — | Every other native <div> attribute is forwarded, such as aria-label; className is merged last with cn(). |
Usage
Needs the stylesheets from @cybr/tokens and @cybr/effects. After the install, import them in app/globals.css:
@import "tailwindcss";
@import "../styles/cybr-tokens.css";
@import "../styles/cybr-effects.css";
import { TickerMarquee } from "@/components/ui/ticker-marquee";
export function News() {
return <TickerMarquee aria-label="Latest updates" items={["// REV 0.1 LIVE", "// SYS:OK", "// NEW DROP"]} />;
}
States
- Running: the items scroll right to left at a steady, linear speed.
- Hover or focus inside: the scroll pauses and resumes when the pointer or focus leaves.
- Paused by the button: stays paused until PLAY is pressed, even after the pointer leaves.
- Reduced motion: the band is static, showing the first items, and the button is not rendered.
- Empty: with no items, the band renders with no track and no button.
Accessibility
- The items render once as a list. The second copy that makes the loop seamless is
aria-hidden, so screen readers hear each item once. - Moving content that runs longer than 5 seconds needs a way to pause it (WCAG 2.2.2). The visible PAUSE/PLAY button gives keyboard and touch users that control, and hover and focus pause it too.
- The button's visible text is in its accessible name ("PAUSE ticker" / "PLAY ticker"). Its focus outline is
--ink, because a--signaloutline would not show on the--signalband. - The scroll is a smooth slide with no flashing.
--ground-deeptext on--signal, the overview's text-on-accent pair. If you override colours withclassName, keep text contrast at 4.5:1 or above.