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 improvementInstall
npx shadcn@latest add https://plinthui.com/r/stepper.json'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>
</>
);
}