All patterns

Progress Dots Advance

The dot for the current step stretches into a pill, and moving on hands that width to the next one.

onboardingminimalfriendlyinteraction · finite · starter · ~0.3s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

252 lines · react + motion only
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Progress Dots Advance
 *
 * The dot for the current step is a pill. Moving on stretches the next
 * dot open and lets the old one close, so the row reads as one marker
 * sliding along rather than five lights switching on and off.
 *
 * Self-contained: depends only on `react` and `motion`. Neutrals are
 * mixed from the inherited text color, so it reads on light and dark
 * pages alike. Works with zero props; tune via `variant`, `steps`,
 * `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ProgressDotsAdvanceProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Step captions. The embedded sample is used when omitted. */
  steps?: string[];
  /** Label of the primary button on the last step. */
  finishLabel?: string;
  /** Active pill color. */
  accent?: string;
  /** Fires with the index the flow just moved to. */
  onStepChange?: (index: number) => void;
};

type VariantConfig = {
  /** Width of the inactive dot, in px. */
  dot: number;
  /** Width the active dot stretches to, in px. */
  pill: number;
  widthSpring: { type: "spring"; stiffness: number; damping: number };
  /** px the caption travels as it swaps. */
  captionRise: number;
};

// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8 — a pill that overshoots its width wobbles the whole row, because
// every dot after it is pushed along. Variants change how far the pill
// stretches and how fast, never how much it settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short pill. For a five-step flow where this is always on screen.
  subtle: {
    dot: 6,
    pill: 18,
    widthSpring: { type: "spring", stiffness: 560, damping: 46 },
    captionRise: 5,
  },
  // Enough stretch to read as a marker travelling. All-purpose.
  default: {
    dot: 7,
    pill: 26,
    widthSpring: { type: "spring", stiffness: 440, damping: 38 },
    captionRise: 8,
  },
  // A long pill and a slower slide, for a short, deliberate flow.
  playful: {
    dot: 8,
    pill: 34,
    widthSpring: { type: "spring", stiffness: 340, damping: 32 },
    captionRise: 11,
  },
};

const SAMPLE_STEPS = [
  "Name your workspace",
  "Invite your team",
  "Connect your tools",
  "Pick a starting template",
];

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ProgressDotsAdvance({
  variant = "default",
  steps = SAMPLE_STEPS,
  finishLabel = "Start over",
  accent = "#5B5BD6",
  onStepChange,
}: ProgressDotsAdvanceProps) {
  const [[index, direction], setState] = useState<[number, number]>([0, 1]);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const isLast = index === steps.length - 1;

  const go = (next: number, dir: number) => {
    setState([next, dir]);
    onStepChange?.(next);
  };

  return (
    <div
      style={{
        width: 300,
        boxSizing: "border-box",
        padding: "18px 18px 16px",
        borderRadius: 18,
        border: `1px solid ${tone(12)}`,
        background: tone(5),
      }}
    >
      <div style={{ fontSize: 11, fontWeight: 650, letterSpacing: 0.4, opacity: 0.45 }}>
        {`SETTING UP · ${index + 1}/${steps.length}`}
      </div>

      {/* The caption swaps in the direction of travel at a constant font
          size. Text moves, never scales. */}
      <div style={{ position: "relative", height: 24, marginTop: 8 }}>
        <AnimatePresence initial={false} custom={direction}>
          <motion.div
            key={index}
            custom={direction}
            initial={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: direction * cfg.captionRise }
            }
            animate={{ opacity: 1, y: 0 }}
            exit={
              reduceMotion
                ? { opacity: 0 }
                : { opacity: 0, y: -direction * cfg.captionRise }
            }
            transition={{ duration: 0.22, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              fontSize: 15,
              fontWeight: 650,
              letterSpacing: -0.2,
            }}
          >
            {steps[index]}
          </motion.div>
        </AnimatePresence>
      </div>

      <div
        role="tablist"
        aria-label="Setup steps"
        style={{
          display: "flex",
          alignItems: "center",
          gap: 4,
          margin: "14px 0 16px",
        }}
      >
        {steps.map((step, dotIndex) => {
          const active = dotIndex === index;
          return (
            <button
              key={step}
              type="button"
              role="tab"
              aria-selected={active}
              aria-label={step}
              onClick={() => go(dotIndex, dotIndex >= index ? 1 : -1)}
              style={{
                padding: "8px 2px",
                background: "transparent",
                border: "none",
                color: "inherit",
                cursor: "pointer",
                lineHeight: 0,
              }}
            >
              {/* Width, not scaleX: the neighbouring dots have to be
                  pushed along by the pill opening, and a scaled dot would
                  slide over them instead of moving them. */}
              <motion.span
                initial={false}
                animate={{ width: active ? cfg.pill : cfg.dot }}
                transition={reduceMotion ? { duration: 0 } : cfg.widthSpring}
                style={{
                  position: "relative",
                  display: "block",
                  height: cfg.dot,
                  borderRadius: 999,
                  background: dotIndex < index ? tone(30) : tone(16),
                  overflow: "hidden",
                }}
              >
                {/* The fill is a layer whose opacity animates rather than
                    an animated background-color: theme-adaptive neutrals
                    are color-mix() values, which no engine interpolates. */}
                <motion.span
                  initial={false}
                  animate={{ opacity: active ? 1 : 0 }}
                  transition={{ duration: 0.2, ease: "easeOut" }}
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 999,
                    background: accent,
                  }}
                />
              </motion.span>
            </button>
          );
        })}
      </div>

      <div style={{ display: "flex", gap: 8 }}>
        <button
          type="button"
          onClick={() => go(Math.max(0, index - 1), -1)}
          disabled={index === 0}
          style={{
            padding: "9px 14px",
            fontSize: 12.5,
            fontWeight: 600,
            fontFamily: "inherit",
            color: "inherit",
            background: "transparent",
            border: `1px solid ${tone(16)}`,
            borderRadius: 10,
            opacity: index === 0 ? 0.35 : 1,
            cursor: index === 0 ? "default" : "pointer",
          }}
        >
          Back
        </button>
        <button
          type="button"
          onClick={() => go(isLast ? 0 : index + 1, 1)}
          style={{
            flex: 1,
            padding: "9px 14px",
            fontSize: 12.5,
            fontWeight: 650,
            fontFamily: "inherit",
            color: "#ffffff",
            background: accent,
            border: "none",
            borderRadius: 10,
            cursor: "pointer",
          }}
        >
          {isLast ? finishLabel : "Continue"}
        </button>
      </div>
    </div>
  );
}

About this pattern

A row of dots where one is always a pill. Advancing stretches the next dot open while the previous one closes, and because the change is real width rather than scaleX, every dot after it is pushed along — which is what makes the row read as a single marker travelling instead of five lights switching. The width runs on a spring that does not overshoot, since an overshoot here wobbles the whole row. The caption above swaps in the direction of travel at a constant font size, and the accent fill is a layer whose opacity animates rather than an animated color, because theme-adaptive neutrals are color-mix() values and no engine can interpolate those.

Onboarding footerIntro carousel indicatorMulti-step formPaged sheet

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • 10:15
    Home
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmedDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    HomeSearchActivityProfile
    Mobile navigation

    Paging dots where the current page is drawn wider than the rest.

Related patterns