An auto-advancing slide progress indicator, stories-style: a pill of dots whose
active dot stretches into a bar that fills over the slide's duration, plus a
detached pause circle. With more slides than clip, the strip becomes a
tape counter — the active bar stays centered, pinned at the deck's ends. Tap a dot
to jump, swipe the pill (or the slides) for prev/next; pausing composes, so hovering
away never cancels an explicit pause.
Install: npx shadcn@latest add @lloydhumphreys/slide-stepper
Everything above is one createSlideStepperCarousel() call. The pause set
is doing the quiet work: hovering holds a 'hover' reason, switching tabs a
'hidden' one, scrolling the stage away an 'offscreen' one, a
swipe-in-progress a 'gesture' one — and the button toggles
'user'. The timer runs only while the set is empty, so clicking pause and
then wandering off keeps it paused, exactly as you'd hope.
With Clip at 5-of-10 the strip translates so the active bar sits dead-center
once you're past the middle and pins flush at the ends — pure CSS
calc()/clamp() from three integers. Set End to Stop
and let it finish: the pause circle becomes Replay. Mixed timing gives slide 1
two seconds and slide 4 nine, via durations: { 0: 2000, 3: 9000 }.
Bring your own content — engine + pill, no carousel
The decoupled mode: the pill owns the engine and exposes it
(createSlideStepper({ count }).engine); a hand-rolled card re-renders from
the same engine.subscribe(). Anything can be the "slide" — the indicator
never needs to know. Equally valid the other way round: create the engine yourself and
pass it in as { engine } — its clock starts on the first
subscribe() (construction is side-effect-free). In React this is
the useSlideStepper() hook and <SlideStepper engine={…} />.
The vanilla core (slide-stepper.ts + slide-stepper-carousel.ts,
zero dependencies). The React wrappers (slide-stepper-react.tsx,
slide-stepper-carousel-react.tsx) expose the same options as props plus a
useSlideStepper hook; slide-stepper-shadcn is a separate
registry item with the whole thing rebuilt from Tailwind tokens, shadcn's
<Button>, and lucide icons — self-contained in one file.
import { createSlideStepperCarousel } from './slide-stepper-carousel'
const carousel = createSlideStepperCarousel({
slides: [imgA, imgB, cardC], // HTMLElements (or (i) => HTMLElement, with count)
duration: 5000,
durations: { 2: 9000 }, // slide 3 runs longer
clip: 5, // tape-counter window
loop: true,
onChange(i, prev, reason) { analytics(reason) },
})
host.appendChild(carousel.element)
// …or decoupled: one engine, your own content
import { createStepperEngine, createSlideStepper } from './slide-stepper'
const engine = createStepperEngine({ count: 8, duration: 4000 })
engine.subscribe(({ index }) => showMySlide(index))
engine.start() // explicit; safe to call more than once
host.appendChild(createSlideStepper({ engine, clip: 5 }).element)
createStepperEngine(options) → StepperEngine| Option | Type | |
|---|---|---|
count | number | How many slides. Required. |
duration | number = 5000 | Default per-slide ms. |
durations | number[] | Record<number, number> | Sparse per-slide overrides by index; missing entries fall back to
duration. |
loop | boolean = true | Wrap after the last slide. Off: the deck stops (done) and the
pause button becomes Replay. |
startPaused | boolean | Begin with a 'user' pause held. |
index | number = 0 | Initial slide. |
onChange | (index, prev, reason) → void | Index changed. reason is 'advance' (timer),
'loop', 'next'/'prev' (swipe/arrows), or
'goto' (tap, Home/End, replay). |
onComplete | (index) → void | A slide's timer finished, just before leaving it. |
onPauseChange | (paused, reasons) → void | The timer stopped/started — fires on empty↔non-empty transitions of the reason set, not on every reason. |
StepperEngine| Member | |
|---|---|
getState() |
{ index, count, progress, paused, pauseReasons, done } —
progress is computed from performance.now() on
demand; the engine never ticks. |
start() |
Start auto-advance. Idempotent, so every mounted consumer may call it safely; subscribing alone does not start the clock. |
subscribe(fn) |
State-change notifications (index/pause/done — not continuous). Returns unsubscribe. |
next() / prev() |
Step one slide; wraps only when looping. |
goTo(i, { restart }) |
Jump; restarts that slide's progress (default) and clears done. |
pause(reason?) / resume(reason?) |
Add/remove a reason ('user' default; also 'hover',
'hidden', 'offscreen', 'gesture',
'focus'). Runs only while the set is empty; resuming continues
from the frozen fraction. |
toggleUserPause() |
The pause button: toggles 'user' — or replays from slide 1 when
done. |
durationFor(i) / isPausedBy(r) |
Lookups. |
setOptions(patch) |
Patch count/duration(s)/loop live; in-flight elapsed time is preserved. |
destroy() | Stop the timer, drop subscribers. |
createSlideStepper(options) → SlideStepper — the pill| Option | Type | |
|---|---|---|
engine | StepperEngine | Share an engine you own; engine options on this object are then ignored. Omitted: the pill creates its own from the engine options above. |
orientation | 'horizontal' | 'vertical' | Vertical stacks the dots and puts the pause circle below. |
clip | number | Max dots visible; more slides become the clamped, centered tape counter. Undefined or ≥ count shows everything. |
showPause | boolean = true | The detached pause/play/replay circle. |
pauseOnHover | boolean = true | Hold 'hover' while pointing at the pill (fine pointers only). |
pauseWhenHidden | boolean = true | Hold 'hidden' while the tab is backgrounded. |
pauseWhenOffscreen | boolean = true | Hold 'offscreen' while scrolled out of view
(offscreenThreshold tunes the IntersectionObserver). |
size | 'sm' | 'md' | 'lg' | Geometry preset; every dimension is also a --stepper-* variable. |
slideIds | (string | undefined)[] | Ids of your own slide elements → each dot's aria-controls. |
labels | { root?, slide?, pause?, play?, replay? } | Accessible names. |
injectStyles | boolean = true | Auto-inject CSS; set false and ship stepperStyles() yourself. |
className | string | Extra class(es) on the root. |
Instance: element, engine, getState(),
next()/prev()/goTo(i),
pause()/resume()/toggle(),
setState(patch), destroy(). Interactions: tap a dot (or the
pill padding — nearest dot wins) to jump; swipe along the pill for prev/next; arrows,
Home and End on the focused strip; Enter/Space on the pause circle.
createSlideStepperCarousel(options) → SlideStepperCarousel| Option | Type | |
|---|---|---|
slides | HTMLElement[] | (i) → HTMLElement | The content. A factory is called lazily (active slide + neighbors, cached);
pass count with it. |
transitionMs | number = 300 | Crossfade duration. |
pillPosition | 'top' | 'bottom' | 'left' | 'right' | Default bottom (horizontal pill) / right (vertical). |
swipe | boolean = true | Swipe the slide area for prev/next (axis follows orientation; page scroll on
the cross-axis is left alone via touch-action). |
pauseOnFocusWithin | boolean = true | Hold 'focus' while keyboard focus is inside — the can't-hover
equivalent of hover-pause (WCAG 2.2.2). |
…plus every pill and engine option above. Instance:
element, stepper, engine,
destroy(). | ||
| Export | |
|---|---|
attachAutoPause(el, engine, opts) |
The hover/hidden/offscreen wiring as a standalone — attach it to any element (returns detach, which also releases its reasons). |
attachSwipeNav(el, opts) |
The threshold-based swipe recognizer (returns detach). Swallows the click after a swipe so a drag that started on a button doesn't also press it. |
injectStepperStyles / stepperStyles /
injectCarouselStyles / carouselStyles |
Inject the stylesheets / get their CSS text (SSR or CSP setups). |
The JS timer is the authoritative clock; CSS only displays progress and is never
listened to — so pause freezes the bar at its exact fraction and resume continues
from it, and aggressive prefers-reduced-motion resets can't skew timing.
Engines are side-effect-free at construction and start explicitly with
start(). Pills and React hooks call it when they mount; it is idempotent,
so composed consumers can all call it without resetting progress.
Under reduced motion the strip glide, bar stretch and crossfade all snap; the fill
sweep is kept deliberately — it is the progress information. The strip does
not mirror under RTL on purpose: it's elapsed time, not reading order, matching
stories UIs. No aria-live on auto-advancing content (per APG carousel
guidance) — compliance comes from the pause set instead.
Consumes shadcn theme tokens when present (--muted,
--muted-foreground, --border, --primary,
--foreground, --ring) with zinc light-dark()
fallbacks — standalone it still reads correctly in both themes. Override
independently via --stepper-pill-bg, --stepper-dot,
--stepper-bar-bg, --stepper-fill,
--stepper-pause-fg, --stepper-ring,
--stepper-radius, --stepper-dot-size,
--stepper-bar-size, --stepper-gap,
--stepper-hit-size, --stepper-pause-gap,
--stepper-strip-ms, --stepper-crossfade-ms,
--stepper-crossfade-scale.