Step Dots
betaA row of dots that shows progress through steps, like an onboarding flow, with each step named for screen readers.
Preview
neutral
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
brand
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
danger
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
success
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
warning
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
first
- Step 1 of 5: Account, current
- Step 2 of 5: Profile, upcoming
- Step 3 of 5: Team, upcoming
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
last
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, done
- Step 4 of 5: Billing, done
- Step 5 of 5: Invite, current
all done
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, done
- Step 4 of 5: Billing, done
- Step 5 of 5: Invite, done
back
- Step 5 of 5: Invite, upcoming
Click a done step to go back to it.
any
Click any step.
size
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
- Step 1 of 5: Account, done
- Step 2 of 5: Profile, done
- Step 3 of 5: Team, current
- Step 4 of 5: Billing, upcoming
- Step 5 of 5: Invite, upcoming
Install
1. Add the registry to
components.json, once per app. Without it, the shadcn CLI looks up@txbrendanin a public list that txbrendan/ui does not control.npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.
npx shadcn@latest add @txbrendan/theme3. Add the component.
npx shadcn@latest add @txbrendan/step-dots
Use it for
- Showing where someone is in a short flow with a few steps: onboarding, a setup wizard, a checkout.
- Pass one name per step. Sighted people see dots, and screen readers read each name, its place, and whether it is done.
- Set jump='back' to let people click a done step to go back to it. Set jump='any' when every step can be done in any order.
- Set current to steps.length when the flow is finished, so every dot shows as done.
- For another language, pass aria-label, stepText, and stateText.
Avoid it for
- Flows where people need to read the step names. Dots show no text, so use a list or tabs with visible labels.
- More than about eight steps. The dots get hard to count. Show 'Step 3 of 12' as text instead.
- Paging through a carousel of slides. Dots here mean progress, and done steps fill in.
- Moving focus for the app. A jump only changes the current step. Your app shows that step and moves focus to it.
Props
- steps
One name per step, in order. Screen readers read each name with 'Step 2 of 4' and its state.
readonly string[]
- current
The current step, counted from 0. Steps before it are done. Set it to steps.length when all are done. Use it with onCurrentChange.
number
- defaultCurrent
The first current step when you do not control it.
number · default 0
- onCurrentChange
Called with the step someone clicked. Clicking the current step does not call it.
(index: number) => void
- jump
Which dots are buttons. back: done steps and the current one. any: every step. none: no buttons.
"none" | "back" | "any" · default "none"
- tone
Color of the done and current dots.
"neutral" | "brand" | "danger" | "success" | "warning" · default "neutral"
- aria-label
Names the list. Pass a better name, like 'Setup progress'. With aria-labelledby set, the default is dropped.
string · default "Progress"
- stepText
What screen readers hear for each step. {step} is its place, {total} the number of steps, {name} its name, and {state} a word from stateText.
string · default "Step {step} of {total}: {name}, {state}"
- stateText
The words for {state} in stepText.
{ done: string; current: string; upcoming: string } · default { done: "done", current: "current", upcoming: "upcoming" }
- className
Extra classes for the list. Later classes win, so className='[--step-dots-size:6px]' makes the dots smaller.
string
| Prop | Type | Default | What it does |
|---|---|---|---|
| steps | readonly string[] | - | One name per step, in order. Screen readers read each name with 'Step 2 of 4' and its state. |
| current | number | - | The current step, counted from 0. Steps before it are done. Set it to steps.length when all are done. Use it with onCurrentChange. |
| defaultCurrent | number | 0 | The first current step when you do not control it. |
| onCurrentChange | (index: number) => void | - | Called with the step someone clicked. Clicking the current step does not call it. |
| jump | "none" | "back" | "any" | "none" | Which dots are buttons. back: done steps and the current one. any: every step. none: no buttons. |
| tone | "neutral" | "brand" | "danger" | "success" | "warning" | "neutral" | Color of the done and current dots. |
| aria-label | string | "Progress" | Names the list. Pass a better name, like 'Setup progress'. With aria-labelledby set, the default is dropped. |
| stepText | string | "Step {step} of {total}: {name}, {state}" | What screen readers hear for each step. {step} is its place, {total} the number of steps, {name} its name, and {state} a word from stateText. |
| stateText | { done: string; current: string; upcoming: string } | { done: "done", current: "current", upcoming: "upcoming" } | The words for {state} in stepText. |
| className | string | - | Extra classes for the list. Later classes win, so className='[--step-dots-size:6px]' makes the dots smaller. |
Accessibility
- Renders an <ol>, so screen readers say how many steps there are.
- Each step has hidden text, like 'Step 2 of 4: Profile, current'. stepText and stateText change the words. The dots themselves are hidden from screen readers.
- The current step has aria-current="step".
- With jump on, a clickable step is a native <button>, so Tab, Enter, and Space work. Steps people cannot reach are not buttons.
- The current step stays a button while jump is on, so focus stays on the dot someone pressed when it becomes current.
- Each dot sits in a cell at least 24px square, the smallest click target WCAG 2.2 allows.
- The current dot is a wider pill, so it is not marked by color alone. Done dots use the tone fill and upcoming dots use --input. The theme tests hold both to 3:1 on the page and on cards.
- The pill grows and shrinks over --motion-duration-base. data-motion="off" makes it instant. Reduced motion keeps the color change and drops the change of width.
Examples
Onboarding progress
<StepDots
aria-label="Setup progress"
steps={["Account", "Profile", "Team", "Invite"]}
current={1}
/>Go back to a done step
"use client";
import { useState } from "react";
const steps = ["Cart", "Shipping", "Payment", "Review"];
function CheckoutSteps() {
const [step, setStep] = useState(2);
return (
<StepDots
aria-label="Checkout progress"
steps={steps}
current={step}
onCurrentChange={setStep}
jump="back"
tone="brand"
/>
);
}Every step done
const steps = ["Account", "Profile", "Team"];
<StepDots aria-label="Setup progress" steps={steps} current={steps.length} tone="success" />In another language
<StepDots
aria-label="Fortschritt der Einrichtung"
steps={["Konto", "Profil", "Team", "Einladen"]}
current={1}
stepText="Schritt {step} von {total}: {name}, {state}"
stateText={{ done: "erledigt", current: "aktuell", upcoming: "offen" }}
/>