← all demos

slide-stepper

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

Orientation
Clip
Timing
End
Size
Theme
Slides advance on their own — hover to pause, swipe or tap dots to navigate.

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={…} />.

API

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

OptionType
countnumber How many slides. Required.
durationnumber = 5000 Default per-slide ms.
durationsnumber[] | Record<number, number> Sparse per-slide overrides by index; missing entries fall back to duration.
loopboolean = true Wrap after the last slide. Off: the deck stops (done) and the pause button becomes Replay.
startPausedboolean Begin with a 'user' pause held.
indexnumber = 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

OptionType
engineStepperEngine 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.
clipnumber Max dots visible; more slides become the clamped, centered tape counter. Undefined or ≥ count shows everything.
showPauseboolean = true The detached pause/play/replay circle.
pauseOnHoverboolean = true Hold 'hover' while pointing at the pill (fine pointers only).
pauseWhenHiddenboolean = true Hold 'hidden' while the tab is backgrounded.
pauseWhenOffscreenboolean = 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.
injectStylesboolean = true Auto-inject CSS; set false and ship stepperStyles() yourself.
classNamestring 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

OptionType
slidesHTMLElement[] | (i) → HTMLElement The content. A factory is called lazily (active slide + neighbors, cached); pass count with it.
transitionMsnumber = 300 Crossfade duration.
pillPosition'top' | 'bottom' | 'left' | 'right' Default bottom (horizontal pill) / right (vertical).
swipeboolean = true Swipe the slide area for prev/next (axis follows orientation; page scroll on the cross-axis is left alone via touch-action).
pauseOnFocusWithinboolean = 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().

Helpers

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).

Behavior notes

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.

Theming

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.