Progress Dots Advance
The dot for the current step stretches into a pill, and moving on hands that width to the next one.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
Paging dots where the current page is drawn wider than the rest.
Related patterns
- Connect IntegrationThe switch throws, the status swaps to connected, and the row opens to list what it will now sync.
- Goal PickerChosen goal tiles paint themselves and take a mark, and the continue action rises the moment the first one is picked.
- Invite Team SendEach address leaves the compose box and travels into a waiting seat, then marks itself sent.