Signal roller
Live account signals roll upward through a faded window, with a fresh arrival lighting up now and then.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
type Kind = "view" | "risk" | "seats" | "reply" | "meeting" | "job" | "docs";
const SIGNALS: { kind: Kind; who: string; text: string; time: string }[] = [
{ kind: "view", who: "Acme", text: "opened the pricing page", time: "2m" },
{ kind: "risk", who: "Northwind", text: "renewal risk: usage down 30%", time: "5m" },
{ kind: "seats", who: "Globex", text: "added 12 seats", time: "9m" },
{ kind: "reply", who: "Initech", text: "replied to your sequence", time: "14m" },
{ kind: "meeting", who: "Umbrella", text: "booked a demo for Thursday", time: "21m" },
{ kind: "job", who: "Hooli", text: "champion moved to a new role", time: "32m" },
{ kind: "docs", who: "Stark", text: "read the security docs 3 times", time: "40m" },
];
const ICONS: Record<Kind, ReactNode> = {
view: <><path d="M1.5 8S4 3.5 8 3.5 14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8Z" /><circle cx="8" cy="8" r="2" /></>,
risk: <><path d="M8 2.5 14 13H2Z" /><path d="M8 6.5v3M8 11.3v.2" /></>,
seats: <><circle cx="6" cy="5.5" r="2.2" /><path d="M2 13c.4-2.4 2-3.6 4-3.6s3.6 1.2 4 3.6M12 5v4M10 7h4" /></>,
reply: <><rect x="2" y="3.5" width="12" height="9" rx="1.5" /><path d="m2.5 4.5 5.5 4 5.5-4" /></>,
meeting: <><rect x="2.5" y="3.5" width="11" height="10" rx="1.5" /><path d="M2.5 7h11M5.5 2v3M10.5 2v3" /></>,
job: <><rect x="2" y="5" width="12" height="8" rx="1.5" /><path d="M6 5V3.5h4V5M2 9h12" /></>,
docs: <><path d="M4 2h5.5L12.5 5v9h-8.5Z" /><path d="M9.5 2v3h3M6 8.5h4.5M6 11h3" /></>,
};
const SPEED = 22; // pixels per second
const FRESH_EVERY = 3600; // ms between highlighted arrivals
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const track = useRef<HTMLUListElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [paused, setPaused] = useState(false);
const [held, setHeld] = useState(false); // hover or focus inside the list
useEffect(() => {
// Read once here: a frame can still run after unmount.
const list = track.current;
const view = list?.parentElement;
if (!list || !view || reduced || paused || held || !inView) return;
const rows = [...list.children] as HTMLElement[];
// The list holds the signals twice; wrapping at the height of one copy makes the roll seamless.
const loop = rows[SIGNALS.length].offsetTop - rows[0].offsetTop;
let offset = Number(list.dataset.offset ?? 0);
let raf = 0, last = performance.now(), sinceFresh = 0;
const frame = (now: number) => {
const dt = Math.min(now - last, 64);
last = now;
offset = (offset + (dt / 1000) * SPEED) % loop;
list.style.transform = `translateY(${-offset}px)`;
list.dataset.offset = String(offset);
sinceFresh += dt;
if (sinceFresh > FRESH_EVERY) {
sinceFresh = 0;
// Flag the row that is just coming up over the bottom edge as a new arrival.
const edge = offset + view.clientHeight * 0.72;
const row = rows.find((r) => r.offsetTop > edge);
if (row) {
row.removeAttribute("data-fresh");
void row.offsetWidth;
row.setAttribute("data-fresh", "");
}
}
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [reduced, paused, held, inView]);
useEffect(() => {
if (reduced && track.current) track.current.style.transform = "";
}, [reduced]);
const stopped = reduced || paused;
return (
<div ref={root} className={styles.root}>
<section className={styles.card} aria-label="Live account signals">
<header className={styles.head}>
<span className={styles.live} data-on={!stopped || undefined} aria-hidden="true" />
<p>Signals</p>
{!reduced && (
<button type="button" className={styles.toggle} onClick={() => setPaused((p) => !p)} aria-pressed={paused}>
<svg viewBox="0 0 16 16" aria-hidden="true">
{paused ? <path d="M5 3.5v9l7.5-4.5z" /> : <path d="M4.5 3.5h2.5v9H4.5zM9 3.5h2.5v9H9z" />}
</svg>
{paused ? "Resume" : "Pause"}
</button>
)}
</header>
<div
className={styles.view}
tabIndex={0}
role="group"
aria-label="Signal feed, pauses while hovered or focused"
onPointerEnter={(e) => e.pointerType === "mouse" && setHeld(true)}
onPointerLeave={() => setHeld(false)}
onFocus={() => setHeld(true)}
onBlur={() => setHeld(false)}
>
<ul ref={track} className={styles.track}>
{[...SIGNALS, ...SIGNALS].map((s, i) => (
<li key={i} className={styles.row} aria-hidden={i >= SIGNALS.length || undefined}>
<span className={styles.icon} data-kind={s.kind}>
<svg viewBox="0 0 16 16" aria-hidden="true">{ICONS[s.kind]}</svg>
</span>
<p className={styles.text}><b>{s.who}</b> {s.text}</p>
<time className={styles.time}>{s.time} ago</time>
</li>
))}
</ul>
</div>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.card {
width: min(460px, 100%); height: min(340px, 100%); display: grid; grid-template-rows: auto minmax(0, 1fr);
border-radius: 16px; border: 1px solid var(--line); background: var(--white); overflow: hidden;
box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--ink) 45%, transparent);
}
.head { display: flex; align-items: center; gap: 8px; padding: 10px 10px 8px 14px; border-bottom: 1px solid var(--line); font-size: 13px; font-weight: 650; }
.head p { margin: 0; }
.live { position: relative; width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.live[data-on] { background: var(--accent); }
.live[data-on]::after { content: ""; position: absolute; inset: 0; border-radius: 50%; background: var(--accent); animation: ping 1.6s var(--out) infinite; }
@keyframes ping { from { scale: 1; opacity: .6; } to { scale: 2.8; opacity: 0; } }
.toggle {
margin-left: auto; display: inline-flex; align-items: center; gap: 5px; min-height: 32px; padding: 0 10px; border-radius: 999px;
border: 1px solid var(--line); background: var(--white); color: var(--muted); font: inherit; font-size: 12px; font-weight: 650; cursor: pointer;
transition: color .2s, border-color .2s;
}
.toggle:hover { color: var(--ink); border-color: var(--accent-soft); }
.toggle:focus-visible, .view:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.toggle svg { width: 12px; height: 12px; fill: currentColor; }
/* The feed window: the edges fade out so rows roll in and out of nothing. */
.view {
position: relative; overflow: hidden; border-radius: 0 0 15px 15px;
mask-image: linear-gradient(transparent, var(--ink) 22%, var(--ink) 78%, transparent);
}
.view:focus-visible { outline-offset: -2px; }
.track { list-style: none; margin: 0; padding: 0; will-change: transform; }
.row {
display: flex; align-items: center; gap: 10px; height: 48px; padding: 0 14px; box-sizing: border-box;
font-size: 13px; position: relative;
}
.row + .row { box-shadow: inset 0 1px 0 var(--line); }
.row[data-fresh] { animation: fresh 2.6s var(--out); }
@keyframes fresh {
0%, 15% { background: var(--accent-pale); box-shadow: inset 3px 0 var(--accent); }
100% { background: transparent; box-shadow: inset 3px 0 transparent; }
}
.row[data-fresh] .icon { animation: bump .6s var(--out); }
@keyframes bump { 40% { scale: 1.18; } }
.icon {
flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px;
background: var(--sky); color: var(--ink);
}
.icon[data-kind="risk"] { background: var(--sand); }
.icon[data-kind="seats"], .icon[data-kind="meeting"] { background: var(--accent-pale); color: var(--accent-text); }
.icon svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.text { flex: 1; min-width: 0; margin: 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.text b { color: var(--ink); font-weight: 650; }
.time { flex: none; color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
@container (max-height: 270px) {
.root { padding: 10px; }
.row { height: 42px; }
.icon { width: 24px; height: 24px; }
}
@container (max-width: 340px) {
.row { gap: 8px; padding: 0 10px; font-size: 12px; }
.time { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.live[data-on]::after, .row[data-fresh], .row[data-fresh] .icon { animation: none; }
.view { mask-image: linear-gradient(var(--ink) 80%, transparent); }
}
Use it for
- CRM, analytics and sales tools that surface activity
- Hero sections that show a product noticing things for you
- Side panels where a feed should feel alive but stay calm
Avoid
- Feeds people need to read in full, use a static list
- More than one roller on the same screen
How it works
The list is rendered twice and one requestAnimationFrame loop moves it up through a ref, wrapping at the height of one copy so the roll never jumps. A mask image fades the top and bottom edges. Every few seconds the loop flags the row just entering at the bottom with a data attribute whose CSS animation flashes it and bumps its icon. Hovering or focusing the feed, the Pause button and leaving the screen all stop the loop and keep its offset.
Accessibility
Screen readers get one copy of the list (the duplicate is aria-hidden) and the feed pauses while focused or hovered. The Pause button is a real toggle with aria-pressed. Reduced motion shows a still list with no roll and no flashes.