Autoplay progress dots
Slideshow dots where the active one stretches into a pill that fills with the slide's time, then hands off.
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 PointerEvent } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const SLIDES = [
{ kicker: "Plan", title: "The whole trip in one place", tone: "a" },
{ kicker: "Share", title: "Send the itinerary with one link", tone: "b" },
{ kicker: "Offline", title: "Maps for every stop, no signal needed", tone: "c" },
{ kicker: "Split", title: "Costs settle up as you go", tone: "d" },
];
const DURATION = 4200;
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const track = useRef<HTMLDivElement>(null);
const drag = useRef<{ x: number; id: number } | null>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(true);
const [dragging, setDragging] = useState(false);
// Reduced motion: start paused on the first slide; the play button still works.
useEffect(() => { if (reduced) setPlaying(false); }, [reduced]);
const go = (i: number) => setIndex((i + SLIDES.length) % SLIDES.length);
// The fill is a CSS animation whose play state is the timer: paused off screen, while paused, and mid drag.
const running = playing && inView && !dragging;
// Swipe: the track follows the finger through a ref, then snaps to the next or previous slide.
const onDown = (e: PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
drag.current = { x: e.clientX, id: e.pointerId };
setDragging(true);
};
const onMove = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current, t = track.current;
if (!d || !t || d.id !== e.pointerId) return;
if (Math.abs(e.clientX - d.x) > 6 && !e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.setPointerCapture(e.pointerId);
t.style.transform = `translateX(calc(${-index * 100}% + ${e.clientX - d.x}px))`;
};
const onUp = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
setDragging(false);
if (track.current) track.current.style.transform = "";
const dx = e.clientX - d.x;
if (Math.abs(dx) > 40) go(index + (dx < 0 ? 1 : -1));
};
return (
<div ref={root} className={styles.root}>
<section className={styles.gallery} aria-roledescription="carousel" aria-label="Trip planner highlights">
<div
className={styles.viewport}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
>
<div
ref={track}
className={styles.track}
data-dragging={dragging || undefined}
style={{ transform: `translateX(${-index * 100}%)` }}
aria-live={playing ? "off" : "polite"}
>
{SLIDES.map((s, i) => (
<div
key={s.kicker}
className={styles.slide}
data-tone={s.tone}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${SLIDES.length}`}
aria-hidden={i !== index}
>
<span className={styles.orb} aria-hidden="true" />
<span className={styles.orb2} aria-hidden="true" />
<p className={styles.kicker}>{s.kicker}</p>
<p className={styles.title}>{s.title}</p>
</div>
))}
</div>
</div>
<div className={styles.controls}>
<div className={styles.dots}>
{SLIDES.map((s, i) => (
<button
key={s.kicker}
type="button"
className={styles.dot}
aria-label={`Show slide ${i + 1}: ${s.title}`}
aria-current={i === index || undefined}
onClick={() => go(i)}
>
<span className={styles.pill}>
{i === index && (
<span
key={index}
className={styles.fill}
style={{ animationDuration: `${DURATION}ms`, animationPlayState: running ? "running" : "paused" }}
onAnimationEnd={() => go(index + 1)}
/>
)}
</span>
</button>
))}
</div>
<button type="button" className={styles.play} onClick={() => setPlaying((p) => !p)} aria-label={playing ? "Pause slideshow" : "Play slideshow"}>
<svg viewBox="0 0 16 16" aria-hidden="true">{playing ? <path d="M4.5 3.5h2.5v9H4.5zM9 3.5h2.5v9H9z" /> : <path d="M5 3v10l8-5z" />}</svg>
</button>
</div>
</section>
</div>
);
}
demo.module.css
.root {
container-type: size; display: grid; place-items: center; width: 100%; height: 100%; padding: 14px;
box-sizing: border-box; background: var(--surface); font-family: system-ui, sans-serif;
}
.gallery { width: min(620px, 100%); height: 100%; max-height: 400px; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 6px; }
.viewport { overflow: hidden; border-radius: 18px; touch-action: pan-y; user-select: none; cursor: grab; }
.viewport:active { cursor: grabbing; }
.track { display: flex; height: 100%; transition: transform .7s var(--out); }
.track[data-dragging] { transition: none; }
/* Slides are drawn in CSS: a token gradient, two soft orbs and a caption. */
.slide {
position: relative; flex: 0 0 100%; display: flex; flex-direction: column; justify-content: flex-end; gap: 4px;
padding: clamp(14px, 5cqi, 28px); box-sizing: border-box; overflow: hidden; color: var(--ink);
}
.slide[data-tone="a"] { background: linear-gradient(135deg, var(--accent-pale), var(--accent-soft)); }
.slide[data-tone="b"] { background: linear-gradient(135deg, var(--sky), var(--accent-pale) 70%); }
.slide[data-tone="c"] { background: linear-gradient(135deg, var(--sand), var(--sky)); }
.slide[data-tone="d"] { background: linear-gradient(135deg, var(--ink), color-mix(in srgb, var(--ink) 70%, var(--accent-text))); color: var(--white); }
.orb, .orb2 { position: absolute; border-radius: 50%; pointer-events: none; }
.orb { width: 46cqmin; aspect-ratio: 1; top: -10%; right: 8%; background: radial-gradient(circle at 35% 35%, color-mix(in srgb, var(--accent) 25%, var(--accent-pale)), color-mix(in srgb, var(--accent) 60%, transparent) 72%); opacity: .8; }
.orb2 { width: 22cqmin; aspect-ratio: 1; top: 30%; right: 42%; border: 2px solid color-mix(in srgb, currentColor 25%, transparent); }
.slide[data-tone="c"] .orb { background: radial-gradient(circle at 35% 35%, var(--sky), color-mix(in srgb, var(--accent-soft) 80%, transparent) 72%); }
.slide[data-tone="d"] .orb { background: radial-gradient(circle at 35% 35%, var(--accent), color-mix(in srgb, var(--accent-text) 30%, transparent) 70%); opacity: .6; }
.slide p { position: relative; margin: 0; }
.kicker { font-size: 11px; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; opacity: .7; }
.title { max-width: 18ch; font-size: clamp(17px, 6.5cqi, 34px); font-weight: 750; letter-spacing: -0.02em; line-height: 1.08; }
/* Dots: the active one stretches into a pill and fills as its slide's time runs. */
.controls { display: flex; align-items: center; justify-content: center; gap: 6px; }
.dots { display: flex; align-items: center; padding: 0 6px; border-radius: 18px; background: var(--white); border: 1px solid var(--line); }
.dot { display: grid; place-items: center; height: 36px; padding: 0 4px; border: 0; background: transparent; cursor: pointer; }
.dot:focus-visible, .play:focus-visible { outline: 2px solid var(--accent-text); outline-offset: -2px; border-radius: 18px; }
.pill {
position: relative; display: block; width: 8px; height: 8px; border-radius: 4px; overflow: hidden; background: var(--line);
transition: width .5s var(--out), background-color .3s;
}
.dot:hover .pill { background: var(--muted); }
.dot[aria-current] .pill { width: 36px; background: var(--accent-soft); }
.fill { position: absolute; inset: 0; border-radius: inherit; background: var(--accent-text); transform-origin: left; transform: scaleX(0); animation: fill linear forwards; }
@keyframes fill { to { transform: scaleX(1); } }
.play {
display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--line);
background: var(--white); color: var(--ink); cursor: pointer; transition: transform .15s var(--out);
}
.play:active { transform: scale(.92); }
.play svg { width: 13px; height: 13px; fill: currentColor; }
@media (prefers-reduced-motion: reduce) {
.track, .pill { transition: none; }
}
Use it for
- Feature galleries and product tours that advance on their own
- Hero carousels where people should see how long each slide stays
- Onboarding highlights with three to six slides
Avoid
- Slides with long text that people need time to read
- Galleries with more than about eight slides, where dots stop being useful
How it works
The active dot widens from a circle to a pill with a width transition, and a fill inside it runs a linear scaleX CSS animation for the slide's duration. That animation is the timer: animationend advances the slide, and animation-play-state pauses it when the pause button is pressed, the gallery leaves the screen or a swipe is in progress. Swipes move the track through a ref while the finger is down and snap to the next slide past 40px.
Accessibility
The gallery is a labelled carousel with a real pause button, each dot is a button naming its slide with aria-current on the active one, and hidden slides are aria-hidden. Slide changes are announced politely only while paused. Reduced motion starts paused and drops the slide and pill transitions.