Skip to content
Plinth

Navigation

Stepper

Numbered steps with per-item connectors, so the line always lands exactly between two dots.

#navigation#stepper#progress#steps#aria

Original by Plinth ยท MIT

Report a problem or suggest an improvement

Install

npx shadcn@latest add https://plinthui.com/r/stepper.json
Open in v0
'use client';

import { useState } from 'react';

const css = `
.step { width: min(100%, 420px); }

.step__list {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Each item owns the connector to its left, drawn with ::before. Doing it
   per-item rather than as one bar behind the row means the line always
   lands exactly between two dots at any width. */
.step__item {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  flex: 1;
}
.step__item + .step__item::before {
  content: "";
  position: absolute;
  top: 14px;
  right: 50%;
  left: -50%;
  height: 2px;
  background: rgba(255, 255, 255, 0.14);
  transition: background-color 260ms ease;
}
.step__item[data-state="done"]::before,
.step__item[data-state="current"]::before { background: #ffca7a; }

.step__dot {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 2px solid rgba(255, 255, 255, 0.18);
  border-radius: 50%;
  background: #0c0d11;
  color: rgba(255, 255, 255, 0.5);
  font: 600 12px/1 ui-sans-serif, system-ui, sans-serif;
  cursor: pointer;
  transition: border-color 220ms ease, background-color 220ms ease, color 220ms ease;
}
.step__item[data-state="done"] .step__dot {
  border-color: #ffca7a;
  background: #ffca7a;
  color: #191308;
}
.step__item[data-state="current"] .step__dot {
  border-color: #ffca7a;
  color: #ffca7a;
  box-shadow: 0 0 0 4px rgba(255, 202, 122, 0.16);
}
.step__dot:focus-visible { outline: 2px solid #ffca7a; outline-offset: 3px; }
.step__dot:disabled { opacity: 0.4; cursor: not-allowed; }

.step__label {
  font: 500 12px/1.3 ui-sans-serif, system-ui, sans-serif;
  color: rgba(243, 240, 233, 0.5);
  transition: color 220ms ease;
}
.step__item[data-state="done"] .step__label,
.step__item[data-state="current"] .step__label { color: #f3f0e9; }

.step__sr {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .step__dot, .step__label, .step__item::before { transition: none; }
}
`;

export interface Step {
  label: string;
  disabled?: boolean;
}

const STEPS: Step[] = [
  { label: 'Choose' },
  { label: 'Configure' },
  { label: 'Install' },
  { label: 'Done' },
];

export function Stepper({
  steps = STEPS,
  defaultStep = 1,
  currentStep,
  canSelect,
  onChange,
}: {
  steps?: Step[];
  defaultStep?: number;
  /** Controlled selection. Update this from onChange after host validation. */
  currentStep?: number;
  /** Return false to leave the current step unchanged. */
  canSelect?: (nextIndex: number, currentIndex: number) => boolean;
  onChange?: (index: number) => void;
}) {
  const [internal, setInternal] = useState(defaultStep);
  const requested = currentStep ?? internal;
  const current = Number.isFinite(requested) ? Math.max(0, Math.min(steps.length - 1, Math.trunc(requested))) : 0;

  return (
    <>
      <style>{css}</style>
      <nav className="step" aria-label="Progress">
        <ol className="step__list">
          {steps.map((step, i) => {
            const state = i < current ? 'done' : i === current ? 'current' : undefined;
            return (
              <li className="step__item" key={step.label} data-state={state}>
                <button
                  className="step__dot"
                  type="button"
                  disabled={step.disabled}
                  aria-label={`Step ${i + 1}: ${step.label}`}
                  // aria-current is what a screen reader uses to say "you are
                  // here"; the data attribute only drives CSS. Both move
                  // together, or the two disagree.
                  aria-current={i === current ? 'step' : 'false'}
                  onClick={() => {
                    if (i === current || canSelect?.(i, current) === false) return;
                    if (currentStep === undefined) setInternal(i);
                    onChange?.(i);
                  }}
                >
                  <span aria-hidden="true">{i + 1}</span>
                </button>
                <span className="step__label">{step.label}</span>
              </li>
            );
          })}
        </ol>
      </nav>
    </>
  );
}

More in Navigation

Similar tags