Step Progress

The connecting line fills toward the next step and each completed dot pops into a check, so progress through a flow is obvious.

  • CSS
  • Layout
  • Click
  • Added
  1. Cart
  2. Address
  3. Payment
  4. Done

AI prompt

Create a multi-step progress indicator in React and CSS. Lay out the step dots with a track line behind them, and an accent fill line on top whose transform is scaleX(current / (steps − 1)) with transform-origin left and a 420ms cubic-bezier(0.16, 1, 0.3, 1) transition. Each dot has three states via data attributes: done (accent fill with a check that draws in via stroke-dashoffset), current (accent ring with a soft pulsing halo), and upcoming (muted ring). When a dot becomes done, play a 300ms pop (scale 1 → 1.2 → 1), delayed so it lands as the line arrives. Back and Next buttons change the step and disable at each end. Mark the list with aria-current="step" on the current item. Under prefers-reduced-motion, change states without the fill transition or pop. The steps are Cart, Address, Payment, and Done.

Source

Demo.tsx
'use client'

import { useState } from 'react'
import s from './demo.module.css'

export default function StepProgress({ labels }: { labels: Record<string, string> }) {
  const steps = labels.steps.split('|')
  const [current, setCurrent] = useState(1)

  return (
    <div className={s.wrap}>
      <ol className={s.steps} style={{ '--fill': current / (steps.length - 1) } as React.CSSProperties}>
        {steps.map((t, i) => (
          <li
            key={t}
            className={s.step}
            data-state={i < current ? 'done' : i === current ? 'current' : 'upcoming'}
            aria-current={i === current ? 'step' : undefined}
          >
            <span className={s.dot}>
              <svg viewBox="0 0 24 24" aria-hidden="true">
                <path d="M6 12.5l4 4 8-9" />
              </svg>
            </span>
            <span className={s.name}>{t}</span>
          </li>
        ))}
      </ol>
      <div className={s.actions}>
        <button type="button" className={s.btn} disabled={current === 0} onClick={() => setCurrent((c) => c - 1)}>
          {labels.back}
        </button>
        <button type="button" className={s.btn} disabled={current === steps.length - 1} onClick={() => setCurrent((c) => c + 1)}>
          {labels.next}
        </button>
      </div>
    </div>
  )
}
demo.module.css
.wrap {
  display: grid;
  justify-items: center;
  gap: 28px;
  width: min(320px, 100%);
}

.steps {
  position: relative;
  display: flex;
  justify-content: space-between;
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
}

.steps::before,
.steps::after {
  content: '';
  position: absolute;
  left: 12px;
  right: 12px;
  top: 11px;
  height: 2px;
  background: var(--stage-rule);
}

.steps::after {
  background: var(--stage-accent);
  transform: scaleX(var(--fill));
  transform-origin: left;
  transition: transform 420ms var(--ease-out);
}

.step {
  position: relative;
  z-index: 1;
  display: grid;
  justify-items: center;
  gap: 8px;
  width: 24px;
}

.dot {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border: 2px solid var(--stage-rule);
  border-radius: 50%;
  background: var(--stage-bg);
  transition: border-color 200ms, background-color 200ms;
}

.dot svg {
  width: 14px;
  height: 14px;
  fill: none;
  stroke: var(--stage-accent-ink);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 20;
  stroke-dashoffset: 20;
}

.name {
  font: 500 var(--text-xs) / 1 var(--font-body);
  color: var(--stage-muted);
  white-space: nowrap;
}

.step[data-state='current'] .dot {
  border-color: var(--stage-accent);
  animation: halo 1.8s var(--ease-out) infinite;
}

.step[data-state='current'] .name,
.step[data-state='done'] .name { color: var(--stage-ink); }

.step[data-state='done'] .dot {
  border-color: var(--stage-accent);
  background: var(--stage-accent);
  animation: pop 300ms var(--ease-out) 300ms;
}

.step[data-state='done'] .dot svg {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 260ms var(--ease-out) 320ms;
}

@keyframes pop { 50% { transform: scale(1.2); } }

@keyframes halo {
  from { box-shadow: 0 0 0 0 color-mix(in oklch, var(--stage-accent) 50%, transparent); }
  to { box-shadow: 0 0 0 10px transparent; }
}

.actions { display: flex; gap: 8px; }

.btn {
  height: 34px;
  padding: 0 14px;
  border: 1px solid var(--stage-rule);
  border-radius: var(--radius-sm);
  background: var(--stage-surface);
  color: var(--stage-ink);
  font: 500 var(--text-sm) / 1 var(--font-body);
  cursor: pointer;
}

.btn:disabled { color: var(--stage-rule); cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  .steps::after, .step[data-state='done'] .dot svg { transition: none; }
  .step[data-state='done'] .dot, .step[data-state='current'] .dot { animation: none; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.