Snap story carousel
A snapping row of coloured customer stat cards whose big numbers count up as each card lands in view.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const STORIES = [
{ value: 3.2, decimals: 1, suffix: "x", claim: "faster releases after moving CI to the cloud", company: "Northwind Freight", tone: "pale" },
{ value: 48, suffix: "%", claim: "fewer support tickets in the first quarter", company: "Kestrel Health", tone: "ink" },
{ value: 12000, suffix: "+", claim: "engineers onboarded across 30 offices", company: "Orbital Bank", tone: "sky" },
{ value: 99.98, decimals: 2, suffix: "%", claim: "uptime through peak holiday traffic", company: "Lumen Travel", tone: "sand" },
{ value: 6, suffix: " weeks", claim: "from first commit to public launch", company: "Fernway", tone: "soft" },
{ value: 2.4, decimals: 1, prefix: "$", suffix: "M", claim: "saved on infrastructure in one year", company: "Cobalt Retail", tone: "white" },
];
// Invented logo marks, one per card.
const MARKS = [
<path key="a" d="M2 12 8 3l6 9H2Z" />,
<circle key="b" cx="8" cy="8" r="5.5" />,
<path key="c" d="M3 3h10v10H3z" />,
<path key="d" d="M8 2 14 8 8 14 2 8Z" />,
<path key="e" d="M3 13V3l10 10V3" fill="none" stroke="currentColor" strokeWidth="2.2" />,
<path key="f" d="M2 8a6 6 0 0 1 12 0H2Z" />,
];
const COUNT = 1100; // ms for a number to count up
const format = (s: (typeof STORIES)[number], n: number) =>
`${s.prefix ?? ""}${n.toLocaleString("en-US", { minimumFractionDigits: s.decimals ?? 0, maximumFractionDigits: s.decimals ?? 0 })}${s.suffix}`;
export default function Demo() {
const scroller = useRef<HTMLUListElement>(null);
const numbers = useRef<(HTMLSpanElement | null)[]>([]);
const reduced = usePrefersReducedMotion();
const [ends, setEnds] = useState({ start: true, end: false });
// Prev and next disable at the ends; state only changes when an end is reached or left.
useEffect(() => {
const el = scroller.current;
if (!el) return;
const update = () => {
const start = el.scrollLeft < 4;
const end = el.scrollLeft > el.scrollWidth - el.clientWidth - 4;
setEnds((p) => (p.start === start && p.end === end ? p : { start, end }));
};
update();
el.addEventListener("scroll", update, { passive: true });
const ro = new ResizeObserver(update);
ro.observe(el);
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
};
}, []);
// Each card counts up once a good part of it is in view inside the scroller, and resets once it leaves.
useEffect(() => {
const el = scroller.current;
const spans = numbers.current.slice();
if (!el) return;
const rafs = new Map<number, number>();
const run = (i: number) => {
const span = spans[i];
const s = STORIES[i];
if (!span || rafs.has(i)) return;
if (reduced) return void (span.textContent = format(s, s.value));
const t0 = performance.now();
const frame = (now: number) => {
const t = Math.min(1, (now - t0) / COUNT);
const eased = 1 - (1 - t) ** 4;
span.textContent = format(s, s.value * eased);
if (t < 1) rafs.set(i, requestAnimationFrame(frame));
};
rafs.set(i, requestAnimationFrame(frame));
};
const io = new IntersectionObserver(
(entries) => entries.forEach((e) => {
const i = Number((e.target as HTMLElement).dataset.index);
if (e.intersectionRatio >= 0.3) run(i);
else if (!e.isIntersecting && !reduced) {
cancelAnimationFrame(rafs.get(i) ?? 0);
rafs.delete(i);
if (spans[i]) spans[i]!.textContent = format(STORIES[i], 0);
}
}),
{ root: el, threshold: [0, 0.3] },
);
Array.from(el.children).forEach((c) => io.observe(c));
return () => {
io.disconnect();
rafs.forEach((id) => cancelAnimationFrame(id));
};
}, [reduced]);
const step = (dir: number) => {
const el = scroller.current;
const card = el?.firstElementChild as HTMLElement | null;
if (!el || !card) return;
const gap = parseFloat(getComputedStyle(el).columnGap) || 0;
el.scrollBy({ left: dir * (card.offsetWidth + gap), behavior: reduced ? "auto" : "smooth" });
};
return (
<div className={styles.root}>
<section className={styles.shell} aria-label="Customer stories">
<ul ref={scroller} className={styles.row} tabIndex={0} aria-label="Customer stories, scroll sideways">
{STORIES.map((s, i) => (
<li key={s.company} className={styles.card} data-tone={s.tone} data-index={i}>
<span className={styles.sr}>{format(s, s.value)} {s.claim}, {s.company}</span>
<span aria-hidden="true" className={styles.company}>
<svg viewBox="0 0 16 16" className={styles.mark}>{MARKS[i % MARKS.length]}</svg>
{s.company}
</span>
<span aria-hidden="true" ref={(el) => { numbers.current[i] = el; }} className={styles.number}>
{format(s, reduced ? s.value : 0)}
</span>
<span aria-hidden="true" className={styles.claim}>{s.claim}</span>
</li>
))}
</ul>
<div className={styles.controls}>
<p className={styles.caption}>Teams shipping faster</p>
<button type="button" className={styles.nav} onClick={() => step(-1)} disabled={ends.start} aria-label="Previous story">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M10 3.5 5.5 8l4.5 4.5" /></svg>
</button>
<button type="button" className={styles.nav} onClick={() => step(1)} disabled={ends.end} aria-label="Next story">
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" /></svg>
</button>
</div>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 16px 0;
box-sizing: border-box; background: var(--surface);
}
.shell { display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 12px; width: min(980px, 100%); height: 100%; max-height: 400px; }
/* The snapping row: cards are a fixed share of the width so the next one always peeks in. */
.row {
display: grid; grid-auto-flow: column; grid-auto-columns: clamp(190px, 58cqi, 270px); gap: 12px; margin: 0; padding: 2px 16px;
list-style: none; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px; scrollbar-width: none;
overscroll-behavior-x: contain;
}
.row::-webkit-scrollbar { display: none; }
.row:focus-visible { outline: 2px solid var(--accent-text); outline-offset: -2px; border-radius: 16px; }
.card {
position: relative; display: grid; grid-template-rows: auto 1fr auto auto; gap: 8px; min-height: 0; padding: 18px; box-sizing: border-box;
border-radius: 16px; scroll-snap-align: start; background: var(--accent-pale); color: var(--ink); overflow: hidden;
}
.card[data-tone="ink"] { background: var(--ink); color: var(--white); }
.card[data-tone="sky"] { background: var(--sky); }
.card[data-tone="sand"] { background: var(--sand); }
.card[data-tone="soft"] { background: var(--accent-soft); }
.card[data-tone="white"] { background: var(--white); border: 1px solid var(--line); }
.number {
grid-row: 3; font-size: clamp(32px, 13cqh, 58px); font-weight: 700; letter-spacing: -0.035em; line-height: 1; font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.card[data-tone="pale"] .number, .card[data-tone="white"] .number { color: var(--accent-text); }
.claim { grid-row: 4; font-size: 14px; line-height: 1.35; font-weight: 500; max-width: 22ch; }
.company { display: flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 700; letter-spacing: .01em; opacity: .85; }
.mark { width: 14px; height: 14px; fill: currentColor; }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.controls { display: flex; align-items: center; gap: 8px; padding: 0 16px; }
.caption { margin: 0 auto 0 0; font-size: 13px; font-weight: 650; color: var(--muted); }
.nav {
display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border-radius: 50%; border: 1px solid var(--line);
background: var(--white); color: var(--ink); cursor: pointer; transition: opacity .25s, transform .18s var(--out), border-color .25s;
}
.nav:hover:not(:disabled) { border-color: var(--ink); }
.nav:active:not(:disabled) { transform: scale(.92); }
.nav:disabled { opacity: .35; cursor: default; }
.nav:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.nav svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
@container (max-height: 300px) {
.root { padding: 12px 0; }
.shell { gap: 8px; }
.card { padding: 14px; gap: 6px; }
.claim { font-size: 13px; }
.caption { font-size: 12px; }
.nav { width: 36px; height: 36px; }
}
@container (min-width: 700px) {
.row { grid-auto-columns: 280px; }
}
@media (prefers-reduced-motion: reduce) {
.row { scroll-behavior: auto; }
.nav { transition: none; }
}
Use it for
- Customer proof sections with a handful of headline results
- Case study rows on landing and pricing pages
- Any short set of cards that should swipe on phones and click on desktop
Avoid
- More than eight cards, where people stop scrolling long before the end
- Numbers people need to compare side by side, which belong in a table
How it works
The row is a native scroller with scroll-snap-type and cards sized from a container query unit, so the next card always peeks in. An IntersectionObserver rooted on the scroller starts a short requestAnimationFrame count when a card is at least 30% visible, writing the formatted number through a ref, and resets it once the card leaves so it counts again next time. Prev and next scroll by one card width plus the gap, and a scroll listener disables them at either end.
Accessibility
Each card carries its full claim as screen reader text, so the counting digits are hidden from assistive tech. The row is focusable for arrow key scrolling and prev and next are real buttons that disable at the ends. Reduced motion shows the final numbers and scrolls without smoothing.