Swish UI
All componentsNavigation and overlays Free

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.

Found on