Qure UI
Components

Stepper

Where somebody is in a flow with a known number of stages.

  1. Select studiesCompleted
  2. De-identifyCompleted
  3. Upload DICOMCurrent step
  4. Submit to ACRNot started
import { Stepper, StepperStep } from '@/registry/qure/ui/stepper'export default function StepperDemo() {  return (    <Stepper className="w-full max-w-2xl" value={2} aria-label="ACR submission progress">      <StepperStep label="Select studies" />      <StepperStep label="De-identify" />      <StepperStep label="Upload DICOM" />      <StepperStep label="Submit to ACR" />    </Stepper>  )}

Installation

npx shadcn@latest add https://qure-ui.qure.ai/r/stepper.json

Usage

import { Stepper, StepperStep } from "@/components/ui/stepper"
<Stepper value={2} aria-label="ACR submission progress">
  <StepperStep label="Select studies" />
  <StepperStep label="De-identify" />
  <StepperStep label="Upload DICOM" />
  <StepperStep label="Submit to ACR" />
</Stepper>

value is the index of the current step. Everything before it is complete, everything after it is not started, and no step is told which it is — the root works it out, so advancing a wizard is one number changing rather than four props.

A stepper is worth its space when the stages are named, ordered and few — three to five. For a count with no names, Progress says the same thing in less room, and for a wait with no known end use Spinner. A stepper is also the wrong component for stages you may visit in any order: those are Tabs.

Vertical

Down the side of a panel, where each stage has room for a line of detail. This is the arrangement to reach for when the labels are longer than two words, because a horizontal row of long labels either wraps or truncates.

  1. DemographicsName, NHS number, date of birthCompleted
  2. ConsentSigned 9 March, awaiting countersignatureCurrent step
  3. Prior imagingLink studies held at the referring trustNot started
  4. Assign clinicianNot startedNot started
import { Stepper, StepperStep } from '@/registry/qure/ui/stepper'export default function StepperVertical() {  return (    <Stepper      className="w-full max-w-sm"      orientation="vertical"      value={1}      aria-label="Patient onboarding progress"    >      <StepperStep label="Demographics" description="Name, NHS number, date of birth" />      <StepperStep label="Consent" description="Signed 9 March, awaiting countersignature" />      <StepperStep label="Prior imaging" description="Link studies held at the referring trust" />      <StepperStep label="Assign clinician" description="Not started" />    </Stepper>  )}

Clickable steps

Pass onStepChange and every step becomes a real <button>: focusable, in the tab sequence, operable by Enter and Space. Without it the steps are plain elements and nothing stops on them.

'use client'import * as React from 'react'import { Button } from '@/registry/qure/ui/button'import { Stepper, StepperStep } from '@/registry/qure/ui/stepper'const STEPS = ['Draft findings', 'Peer review', 'Sign report', 'Distribute']export default function StepperClickable() {  const [step, setStep] = React.useState(2)  return (    <div className="w-full max-w-2xl space-y-6">      <Stepper        value={step}        onStepChange={setStep}        aria-label="Report signing progress"      >        {STEPS.map((label, i) => (          // Steps ahead of the work are disabled: you cannot sign a report you          // have not reviewed, and offering the button implies you can.          <StepperStep key={label} label={label} disabled={i > step} />        ))}      </Stepper>      <div className="flex gap-2">        <Button variant="secondary" size="sm" disabled={step === 0} onClick={() => setStep(s => s - 1)}>          Back        </Button>        <Button size="sm" disabled={step === STEPS.length - 1} onClick={() => setStep(s => s + 1)}>          Continue        </Button>      </div>    </div>  )}

Disable the steps that are not reachable yet. A radiologist cannot sign a report they have not reviewed, and rendering the button anyway invites them to try.

Do not make steps clickable and then have the click do nothing but move a highlight. If pressing "Peer review" does not take the reader back to the review form, it should not be a button — make it static instead. Interactivity that is only decorative is worse than no interactivity, because it costs a tab stop and promises something it will not do.

A step that failed

state overrides what the index implies, which is what makes an error visible. The flow has not advanced — the current step is still where the work is — but the stage that rejected is drawn in urgent, and the message says what went wrong rather than only that something did.

  1. Select studies14 studiesCompleted
  2. De-identifyCompleteCompleted
  3. Upload DICOM3 of 14 rejected — burnt-in identifiersNeeds attention
  4. Submit to ACRNot started
import { Stepper, StepperStep } from '@/registry/qure/ui/stepper'export default function StepperError() {  return (    <Stepper      className="w-full max-w-sm"      orientation="vertical"      value={2}      aria-label="ACR submission progress"    >      <StepperStep label="Select studies" description="14 studies" />      <StepperStep label="De-identify" description="Complete" />      <StepperStep        label="Upload DICOM"        description="3 of 14 rejected — burnt-in identifiers"        state="error"      />      <StepperStep label="Submit to ACR" />    </Stepper>  )}

Sizes

Three, from the control scale — 24, 32 and 40 pixel circles.

  1. De-identifyCompleted
  2. Upload DICOMCurrent step
  3. Submit to ACRNot started
  1. De-identifyCompleted
  2. Upload DICOMCurrent step
  3. Submit to ACRNot started
  1. De-identifyCompleted
  2. Upload DICOMCurrent step
  3. Submit to ACRNot started
import { Stepper, StepperStep } from '@/registry/qure/ui/stepper'export default function StepperSize() {  return (    <div className="w-full max-w-2xl space-y-8">      {(['sm', 'md', 'lg'] as const).map(size => (        <Stepper key={size} size={size} value={1} aria-label={`ACR submission progress, ${size}`}>          <StepperStep label="De-identify" />          <StepperStep label="Upload DICOM" />          <StepperStep label="Submit to ACR" />        </Stepper>      ))}    </div>  )}
SizeUse
sminside a modal header, or a side panel with little room
mdthe default; the top of a wizard or a form page
lga full-page flow where the stepper is the main orientation

Accessibility

The stepper renders as an ordered list — <ol role="list"> with an <li> per step — and not as a tablist. That is the decision worth explaining, because the two look alike and behave nothing alike:

  • A tablist is a set of peers, all reachable, in any order. Its pattern is one tab stop for the whole set and arrow keys to move within it.
  • A stepper is an ordered progression, most of it not reachable yet. There is no set of peers to arrow between, so borrowing the tablist pattern would announce a structure that does not exist and take away the ordinal position, which is the one thing the reader wants: "list, 4 items, item 3 of 4".

role="list" is restated on the <ol> on purpose. Safari drops list semantics from a list whose list-style is none, which is every styled stepper there has ever been.

The current step carries aria-current="step". It sits on the <li> in the static case and on the <button> in the clickable one, because it belongs on whichever element represents the step to the reader.

Static steps are not focusable. There is no tabIndex on them and there is no handler; Tab skips the whole list. A tab stop that does nothing when pressed is a bug, and giving a decorative step one is the usual way this component goes wrong.

Clickable steps are ordinary buttons in ordinary document order, so Tab and Shift + Tab move between them and Enter or Space activates. Arrow keys are deliberately left alone — capturing them would make the steps behave like a tablist they are not. A step that cannot be reached yet is a disabled button rather than a dimmed <div>, so it is announced as unavailable instead of simply being absent.

The circle is aria-hidden: a tick, a number and a warning triangle are three shapes that mean nothing read aloud. Each step carries a visually hidden word for its state instead, so the announcement is "Completed, De-identify" rather than "De-identify" in a colour nobody can hear. STEP_STATE_LABEL exports that wording, so a tooltip or a summary line can reuse it rather than retype it.

API Reference

Stepper

Prop

Type

Give the root an aria-label — "ACR submission progress" — so the list is announced as something rather than as a list of four.

StepperStep

Prop

Type

On this page