Stepper
The steps of a process, horizontal or vertical, with done, current, upcoming and error said in words.
One of 6 components in Navigation elements
Details
What Stepper brings along, read from the registry and the source.
- Category
- Navigation elements
- Area
- Elements
- Variants
- 2
- Version
- 1.0.1 · since 1.0.0
- File
- components/ui/stepper.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 7 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The StepperProps interface from components/ui/stepper.tsx, with the comments from the code.
stepsrequiredStep[]currentrequirednumberIndex of the current step, 0-based. Steps before it count as done.
orientationoptional'horizontal' | 'vertical'markersoptional'numbers' | 'icons'numbers or icons in the markers
compactOnMobileoptionalbooleanOn phones a horizontal stepper becomes "Step 2 of 4" with a bar. Default true.
Built-in texts
Stepper writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 7 texts
| Key | English | German |
|---|---|---|
| label | English Progress | German Fortschritt |
| step | English Step | German Schritt |
| of | English of | German von |
| done | English completed | German erledigt |
| current | English current step | German aktueller Schritt |
| error | English needs attention | German braucht Aufmerksamkeit |
| upcoming | English not started | German noch offen |
Accessibility
Read from the source of the component.
- Sets aria-current and aria-label, so screen readers announce purpose and state.
- Adds text for screen readers where icons and graphics stand alone.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 8 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 197 lines
'use client';
import { useRef } from 'react';
import { cn, round, useInView, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
const TEXTS = {
en: { label: 'Progress', step: 'Step', of: 'of', done: 'completed', current: 'current step', error: 'needs attention', upcoming: 'not started' },
de: { label: 'Fortschritt', step: 'Schritt', of: 'von', done: 'erledigt', current: 'aktueller Schritt', error: 'braucht Aufmerksamkeit', upcoming: 'noch offen' },
};
/* The current step breathes a quiet ring, so the eye finds it */
const STYLES =
'.kitfx-step-ping{animation:kitfx-step-ping 2.6s var(--ease-kit) infinite}@keyframes kitfx-step-ping{0%{opacity:.55;transform:scale(1)}70%,100%{opacity:0;transform:scale(1.6)}}' +
'@media (prefers-reduced-motion:reduce){.kitfx-step-ping{animation:none;opacity:0}}';
/** How long the track takes to fill, the markers light up along the way */
const FILL_MS = 900;
export interface Step {
title: string;
description?: React.ReactNode;
icon?: IconName;
/** Marks a step that failed or needs input */
error?: boolean;
href?: string;
}
export interface StepperProps {
steps: Step[];
/** Index of the current step, 0-based. Steps before it count as done. */
current: number;
orientation?: 'horizontal' | 'vertical';
/** numbers or icons in the markers */
markers?: 'numbers' | 'icons';
/** On phones a horizontal stepper becomes "Step 2 of 4" with a bar. Default true. */
compactOnMobile?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
type State = 'done' | 'current' | 'upcoming' | 'error';
function Marker({ state, index, icon, markers, lit, delay }: { state: State; index: number; icon?: IconName; markers: 'numbers' | 'icons'; lit: boolean; delay: number }) {
// Before the track reaches it, every marker waits in the upcoming look
const shown: State = lit ? state : state === 'error' ? 'error' : 'upcoming';
return (
<span
aria-hidden
className={cn(
'relative z-10 grid size-8 shrink-0 place-items-center rounded-full text-[13px] font-semibold tabular-nums transition-[background-color,border-color,color,box-shadow,scale] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
shown === 'done' && 'border border-transparent bg-primary text-primary-foreground',
shown === 'current' && 'scale-105 border-[1.5px] border-primary bg-background text-foreground shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent)]',
shown === 'upcoming' && 'border border-border-strong bg-background text-subtle-foreground',
shown === 'error' && 'border-[1.5px] border-destructive bg-background text-destructive shadow-[0_0_0_4px_color-mix(in_oklab,var(--destructive)_12%,transparent)]',
)}
style={{ transitionDelay: `${delay}ms` }}
>
{state === 'current' && lit && <span className="kitfx-step-ping pointer-events-none absolute inset-0 rounded-full border border-primary" style={{ animationDelay: `${delay + 400}ms` }} />}
{state === 'done' ? (
<span className="relative grid place-items-center">
<span className={cn('transition-[opacity,scale] duration-300 motion-reduce:transition-none', lit ? 'scale-50 opacity-0' : 'opacity-100')} style={{ transitionDelay: `${delay}ms` }}>
{index + 1}
</span>
<Icon name="check" size={15} strokeWidth={2.2} className={cn('absolute transition-[opacity,scale] duration-500 ease-[cubic-bezier(0.34,1.56,0.64,1)] motion-reduce:transition-none', lit ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} style={{ transitionDelay: `${delay + 80}ms` }} />
</span>
) : state === 'error' ? (
<Icon name="alert-circle" size={16} />
) : markers === 'icons' && icon ? (
<Icon name={icon} size={15} />
) : (
index + 1
)}
</span>
);
}
/**
* The steps of a process: checkout, onboarding, application. Done, current, upcoming and error,
* each said in words to screen readers, not only shown with color. When it scrolls into view the
* track fills up and the markers light up one after another.
* <Stepper current={1} steps={[{ title: 'Cart' }, { title: 'Address' }, { title: 'Payment' }]} />
*/
export function Stepper({ steps, current, orientation = 'horizontal', markers = 'numbers', compactOnMobile = true, labels, className }: StepperProps) {
const t = useTexts(TEXTS, labels);
const state = (i: number, s: Step) => (s.error ? 'error' : i < current ? 'done' : i === current ? 'current' : 'upcoming') as State;
const words = { done: t.done, current: t.current, upcoming: t.upcoming, error: t.error };
const [ref, inView] = useInView<HTMLElement>('0px 0px -10% 0px');
const waited = useRef(false);
if (!inView) waited.current = true;
// Only steps that were waiting below the fold play the fill, everything else stands finished
const play = inView && waited.current;
const gaps = Math.max(1, Math.min(current, steps.length - 1));
const at = (i: number) => (play ? round((Math.min(i, current) / gaps) * FILL_MS) : 0);
const title = (s: Step, st: State) => {
const inner = (
<>
<span className={cn('block text-sm font-medium leading-5', st === 'upcoming' ? 'text-muted-foreground' : st === 'error' ? 'text-destructive' : 'text-foreground')}>{s.title}</span>
<span className="sr-only">, {words[st]}</span>
</>
);
return s.href && st === 'done' ? (
<a href={s.href} className="rounded-sm hover:underline hover:underline-offset-4">
{inner}
</a>
) : (
inner
);
};
if (orientation === 'vertical') {
const per = round(FILL_MS / Math.max(1, current));
return (
<nav ref={ref} aria-label={t.label} className={className}>
<style href="kitfx-step" precedence="default">
{STYLES}
</style>
<ol className="grid">
{steps.map((s, i) => {
const st = state(i, s);
const last = i === steps.length - 1;
return (
<li key={s.title} aria-current={st === 'current' ? 'step' : undefined} className="relative flex gap-4 pb-8 last:pb-0">
{!last && (
<span aria-hidden className="absolute start-4 top-9 bottom-1 w-px -translate-x-1/2 overflow-hidden rounded-full bg-border rtl:translate-x-1/2">
<span
className={cn('block h-full w-full origin-top bg-primary transition-transform ease-linear motion-reduce:transition-none', i < current && inView ? 'scale-y-100' : 'scale-y-0')}
style={{ transitionDuration: `${per}ms`, transitionDelay: `${play ? i * per : 0}ms` }}
/>
</span>
)}
<Marker state={st} index={i} icon={s.icon} markers={markers} lit={inView} delay={play ? Math.min(i, current) * per : 0} />
<div className={cn('min-w-0 pt-1.5 transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-x-2 opacity-0')} style={{ transitionDelay: `${play ? i * 70 : 0}ms` }}>
{title(s, st)}
{s.description && <div className="mt-1 text-pretty text-[13px] leading-relaxed text-muted-foreground">{s.description}</div>}
</div>
</li>
);
})}
</ol>
</nav>
);
}
const share = steps.length > 1 ? current / (steps.length - 1) : 1;
return (
<nav ref={ref} aria-label={t.label} className={cn('w-full', className)}>
<style href="kitfx-step" precedence="default">
{STYLES}
</style>
{compactOnMobile && (
<div className="sm:hidden">
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="font-medium text-foreground">{steps[current]?.title}</span>
<span className="tabular-nums text-muted-foreground">
{t.step} {Math.min(current + 1, steps.length)} {t.of} {steps.length}
</span>
</div>
<div className="mt-2.5 grid gap-1" style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }} aria-hidden>
{steps.map((s, i) => (
<span key={s.title} className="h-1 overflow-hidden rounded-full bg-track">
{(s.error || i <= current) && (
<span
className={cn('block h-full w-full origin-left rounded-full transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', s.error ? 'bg-destructive' : 'bg-primary', inView ? 'scale-x-100' : 'scale-x-0')}
style={{ transitionDelay: `${play ? i * 140 : 0}ms` }}
/>
)}
</span>
))}
</div>
</div>
)}
<ol data-clip-ok={compactOnMobile || undefined} className={cn('relative grid', compactOnMobile && 'max-sm:sr-only')} style={{ gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))` }}>
{/* One track behind the markers, from the first center to the last center */}
<span aria-hidden className="absolute top-4 h-px -translate-y-1/2 bg-border" style={{ left: `calc(50% / ${steps.length})`, right: `calc(50% / ${steps.length})` }}>
<span
className="block h-full w-full origin-left bg-primary transition-transform ease-[cubic-bezier(0.45,0,0.25,1)] motion-reduce:transition-none rtl:origin-right"
style={{ transform: `scaleX(${inView ? round(share, 4) : 0})`, transitionDuration: `${play ? FILL_MS : 500}ms` }}
/>
</span>
{steps.map((s, i) => {
const st = state(i, s);
return (
<li key={s.title} aria-current={st === 'current' ? 'step' : undefined} className="flex min-w-0 flex-col items-center px-2 text-center">
<Marker state={st} index={i} icon={s.icon} markers={markers} lit={inView} delay={at(i)} />
<div className={cn('mt-3 min-w-0 transition-[opacity,translate] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none', !inView && 'translate-y-1.5 opacity-0')} style={{ transitionDelay: `${play ? at(i) : 0}ms` }}>
{title(s, st)}
{s.description && <div className="mt-0.5 text-pretty text-xs text-muted-foreground">{s.description}</div>}
</div>
</li>
);
})}
</ol>
</nav>
);
}Frequent questions
How do I install Stepper?
With aiw add @ai-website/stepper. The command puts components/ui/stepper.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Stepper have?
2 variants, Horizontal and Vertical.
Can I translate the texts of Stepper?
Yes. Stepper carries 7 texts of its own in English and German, such as “Progress”, “Step” and “of”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Stepper?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Stepper with aiw_components and install it with aiw_add @ai-website/stepper.