Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

LayoutReact

Stepper

The steps of a process, one shown at a time, with the current step marked and the steps before it completed.

Rendered by React 19.3.1

Loading React…
import { Stepper } from '@hamur/react/stepper'

const steps = [
  { value: 'address', title: 'Address', body: 'Ship to 12 Analytical Row, London.' },
  { value: 'shipping', title: 'Shipping', body: 'Standard delivery, three to five days.' },
  { value: 'payment', title: 'Payment', body: 'Pay with the card ending in 4242.' },
]

export default function StepperExample() {
  return (
    <Stepper.Root className="stepper" defaultValue="address">
      <Stepper.List className="stepper-list" aria-label="Checkout">
        {steps.map((step, index) => (
          <Stepper.Item
            key={step.value}
            value={step.value}
            className="stepper-item"
          >
            <Stepper.Trigger className="stepper-trigger">
              <Stepper.Indicator className="stepper-indicator" />
              <Stepper.Title className="stepper-title">
                {step.title}
              </Stepper.Title>
            </Stepper.Trigger>
            {index < steps.length - 1 ? (
              <Stepper.Separator className="stepper-separator" />
            ) : null}
          </Stepper.Item>
        ))}
      </Stepper.List>
      {steps.map((step) => (
        <Stepper.Panel
          key={step.value}
          value={step.value}
          className="stepper-panel"
        >
          {step.body}
        </Stepper.Panel>
      ))}
      <div className="stepper-nav">
        <Stepper.Previous className="button">
          Back
        </Stepper.Previous>
        <Stepper.Value className="stepper-value" />
        <Stepper.Next className="button">
          Continue
        </Stepper.Next>
      </div>
    </Stepper.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<Stepper.Root>
  <Stepper.List>
    <Stepper.Item>
      <Stepper.Trigger>
        <Stepper.Indicator />
        <Stepper.Title />
      </Stepper.Trigger>
      <Stepper.Separator />
    </Stepper.Item>
  </Stepper.List>
  <Stepper.Panel />
  <Stepper.Previous />
  <Stepper.Value />
  <Stepper.Next />
</Stepper.Root>

// Not in this example: Stepper.Description

Examples

Tabs

pattern="tabs" with linear={false}: four steps of a new project, one optional, moved between with the arrow keys; the controlled value is shown below.

StepperTabs.tsx
import * as React from 'react'
import { Stepper } from '@hamur/react/stepper'

const steps = [
  { value: 'details', title: 'Details', body: 'Name the project and pick its region.' },
  { value: 'team', title: 'Team', body: 'Invite the people who will work on it.' },
  { value: 'billing', title: 'Billing', body: 'Add a card now or later.', optional: true },
  { value: 'review', title: 'Review', body: 'Check the settings and create the project.' },
]

export default function StepperTabsExample() {
  const [value, setValue] = React.useState('details')
  return (
    <div className="stack">
      <Stepper.Root
        className="stepper"
        pattern="tabs"
        linear={false}
        value={value}
        onValueChange={setValue}
      >
        <Stepper.List className="stepper-list" aria-label="New project">
          {steps.map((step, index) => (
            <Stepper.Item
              key={step.value}
              value={step.value}
              optional={step.optional}
              className="stepper-item"
            >
              <Stepper.Trigger className="stepper-trigger">
                <Stepper.Indicator className="stepper-indicator" />
                <Stepper.Title className="stepper-title">
                  {step.title}
                </Stepper.Title>
              </Stepper.Trigger>
              {index < steps.length - 1 ? (
                <Stepper.Separator className="stepper-separator" />
              ) : null}
            </Stepper.Item>
          ))}
        </Stepper.List>
        {steps.map((step) => (
          <Stepper.Panel
            key={step.value}
            value={step.value}
            className="stepper-panel"
          >
            {step.body}
          </Stepper.Panel>
        ))}
        <div className="stepper-nav">
          <Stepper.Previous className="button">
            Back
          </Stepper.Previous>
          <Stepper.Next className="button">
            Continue
          </Stepper.Next>
        </div>
      </Stepper.Root>
      <p className="muted">Current step: {value}</p>
    </div>
  )
}

Form

A sign-up inside a Form: Next refuses a step while its required field is empty and focuses it, and the last step submits every step's values.

StepperForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'
import { Stepper } from '@hamur/react/stepper'

const steps = [
  { value: 'account', title: 'Account' },
  { value: 'profile', title: 'Profile' },
  { value: 'confirm', title: 'Confirm' },
]

export default function StepperFormExample() {
  const [created, setCreated] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(values) => setCreated(`Created an account for ${String(values.email)}`)}
    >
      <Stepper.Root className="stepper" defaultValue="account">
        <Stepper.List className="stepper-list" aria-label="Sign up">
          {steps.map((step, index) => (
            <Stepper.Item
              key={step.value}
              value={step.value}
              className="stepper-item"
            >
              <Stepper.Trigger className="stepper-trigger">
                <Stepper.Indicator className="stepper-indicator" />
                <Stepper.Title className="stepper-title">
                  {step.title}
                </Stepper.Title>
              </Stepper.Trigger>
              {index < steps.length - 1 ? (
                <Stepper.Separator className="stepper-separator" />
              ) : null}
            </Stepper.Item>
          ))}
        </Stepper.List>
        <Stepper.Panel value="account" className="stepper-panel">
          <Field.Root name="email" className="field">
            <Field.Label>Email</Field.Label>
            <Field.Control type="email" className="input" required placeholder="ada@example.com" />
            <Field.Error className="error" />
          </Field.Root>
        </Stepper.Panel>
        <Stepper.Panel value="profile" className="stepper-panel">
          <Field.Root name="name" className="field">
            <Field.Label>Name</Field.Label>
            <Field.Control className="input" required placeholder="Ada Lovelace" />
            <Field.Error className="error" />
          </Field.Root>
        </Stepper.Panel>
        <Stepper.Panel value="confirm" className="stepper-panel">
          <div className="stack">
            <p className="stepper-note">Check your details, then create the account.</p>
            <button className="button" type="submit">
              Create account
            </button>
          </div>
        </Stepper.Panel>
        <div className="stepper-nav">
          <Stepper.Previous className="button">
            Back
          </Stepper.Previous>
          <Stepper.Value className="stepper-value" />
          <Stepper.Next className="button">
            Continue
          </Stepper.Next>
        </div>
      </Stepper.Root>
      {created ? <p className="muted">{created}</p> : null}
    </Form>
  )
}

Vertical

orientation="vertical": each step's panel under its title, with a Description and Back and Continue inside the panel; the last step is optional.

StepperVertical.tsx
import { Stepper } from '@hamur/react/stepper'

const steps = [
  {
    value: 'repository',
    title: 'Create a repository',
    description: 'Name it and choose who can see it',
    body: 'A public repository named analytical-engine.',
  },
  {
    value: 'readme',
    title: 'Add a README',
    description: 'Say what the project does',
    body: 'A README with a title and one paragraph.',
  },
  {
    value: 'collaborators',
    title: 'Invite collaborators',
    description: 'You can do this later',
    body: 'No one invited yet.',
    optional: true,
  },
]

export default function StepperVerticalExample() {
  return (
    <Stepper.Root
      className="stepper"
      orientation="vertical"
      defaultValue="repository"
    >
      <Stepper.List className="stepper-list" aria-label="New repository">
        {steps.map((step, index) => (
          <Stepper.Item
            key={step.value}
            value={step.value}
            optional={step.optional}
            className="stepper-item"
          >
            <Stepper.Trigger className="stepper-trigger">
              <Stepper.Indicator className="stepper-indicator" />
              <span className="stepper-text">
                <Stepper.Title className="stepper-title">
                  {step.title}
                </Stepper.Title>
                <Stepper.Description className="stepper-description">
                  {step.description}
                </Stepper.Description>
              </span>
            </Stepper.Trigger>
            {index < steps.length - 1 ? (
              <Stepper.Separator className="stepper-separator" />
            ) : null}
            <Stepper.Panel value={step.value} className="stepper-panel">
              <p className="stepper-note">{step.body}</p>
              <div className="stepper-nav">
                {index > 0 ? (
                  <Stepper.Previous className="button">
                    Back
                  </Stepper.Previous>
                ) : null}
                {index < steps.length - 1 ? (
                  <Stepper.Next className="button">
                    Continue
                  </Stepper.Next>
                ) : null}
              </div>
            </Stepper.Panel>
          </Stepper.Item>
        ))}
      </Stepper.List>
    </Stepper.Root>
  )
}

API reference

27 props across 12 parts, in React syntax. className, style, render are listed once, under Shared props.

DescriptionStepper.Description

A description of the step, which describes its Trigger. Renders a <span>.

IndicatorStepper.Indicator

The step's marker, hidden from assistive technology. Shows the step's 1-based position unless it has children. Renders a <span>.

ItemStepper.Item

A step of the process: its Trigger, Indicator, Title, Description and Separator, and in pattern="list" its Panel too. Renders a <li>.

Item props
PropTypeDefault
valuerequiredThe value of the step.any—
disabledWhether the step cannot be activated.booleanfalse
optionalWhether the step does not block reaching the steps after it.booleanfalse
completedWhether the step is completed, overriding the state the Root tracks.boolean—
invalidWhether the step has errors: it shows the error state and, with linear, refuses a forwardboolean—
labelThe step's name in announcements.string—
Item data attributes
Data attributeDescription
data-indexThe 0-based position of the step.
data-currentPresent when the step is the current one.
data-completedPresent when the step is completed.
data-invalidPresent when the step has errors.
data-optionalPresent when the step is optional.
data-disabledPresent when the step is disabled.
data-unreachablePresent when the step cannot be reached yet.
data-orientationIndicates the orientation of the stepper.

ListStepper.List

The list of steps: an ordered list, or the tablist in pattern="tabs". Renders an <ol>.

NextStepper.Next

Goes to the next enabled step, validating the current one with linear. It is never a submit button, and is aria-disabled, not disabled, at the last enabled step. Renders a <button>.

Next props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the button stays focusable while it is disabled, with aria-disabled.booleantrue

PanelStepper.Panel

The content of a step, shown while it is current: a group in pattern="list", a tabpanel in pattern="tabs", labelled by the step's Title. Renders a <div>.

Panel props
PropTypeDefault
valuerequiredThe value of the step this panel belongs to.any—
keepMountedWhether to keep the element in the DOM while the step is not current, so its fields keepbooleantrue
initialFocusThe element to focus when a move made from inside the stepper shows this panel.boolean | React.RefObject<HTMLElement | null> | (() => boolean | void | HTMLElement | null)true
Panel data attributes
Data attributeDescription
data-indexThe 0-based position of the panel's step.
data-hiddenPresent when the panel is hidden.
data-currentPresent when the panel's step is the current one.
data-completedPresent when the panel's step is completed.
data-invalidPresent when the panel's step has errors.
data-orientationIndicates the orientation of the stepper.
data-activation-directionIndicates the direction of the last step change.
data-starting-stylePresent when the panel begins animating in.
data-ending-stylePresent when the panel is animating out.

PreviousStepper.Previous

Goes to the previous enabled step, without validating. It is aria-disabled, not disabled, at the first enabled step, so it keeps focus. Renders a <button>.

Previous props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the button stays focusable while it is disabled, with aria-disabled.booleantrue

RootStepper.Root

Groups all parts of the stepper: the steps of a process, their panels and the Previous and Next buttons., followed by the status region. Renders a <div>.

Root props
PropTypeDefault
valueThe value of the current step.any—
defaultValueThe value of the step that is current when the stepper is first rendered.any0
onValueChangeEvent handler called before the current step changes.((value: any, eventDetails: StepperRootChangeEventDetails) => void)—
patternThe pattern of the stepper.StepperPattern'list'
linearWhether a forward move validates the current step and is refused while it is invalid, and abooleantrue
orientationThe orientation of the step list.StepperOrientation'horizontal'
loopFocus'tabs' only: whether the arrow keys wrap at the ends of the list.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
getStepHref'list' only: the href of each step.((value: any) => string)—
actionsRefA ref to move and validate from the app.React.RefObject<StepperRootActions | null>—
labelsThe texts of the status region, the Value part and the state of each Title.Partial<StepperLabels>—
Root data attributes
Data attributeDescription
data-orientationIndicates the orientation of the stepper.
data-patternIndicates the pattern of the stepper.
data-linearPresent when a forward move validates the current step.
data-disabledPresent when the stepper is disabled.

SeparatorStepper.Separator

The line between steps, hidden from assistive technology and styled by its step's state. Renders a <div>.

TitleStepper.Title

The step's name: it names the Trigger and the Panel, and ends with a visually hidden text of the step's state (completed, error, optional). Renders a <span>.

TriggerStepper.Trigger

Activates its step and is named by the step's Title: a button, a tab in pattern="tabs", or a link when the Root has getStepHref., or an <a> element with getStepHref. Renders a <button>.

Trigger props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
focusableWhenDisabledWhether the Trigger stays focusable while its step is disabled or cannot be reached, withbooleantrue

ValueStepper.Value

The current step's position as text: "Step 2 of 4". Plain text, not a live region. Renders a <span>.

Value props
PropTypeDefault
childrenThe text, or a function of the current step's position, the number of steps, its value andReact.ReactNode | ((values: StepperValueValues) => React.ReactNode)—

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
TabIn 'list', moves through every step's button; the arrow keys do nothingTrigger
TabIn 'tabs', enters the list on the current step's tabList
ArrowRightArrowLeftIn 'tabs', moves to the next or previous tab without changing the stepList
ArrowDownArrowUpIn vertical 'tabs', moves to the next or previous tabList
HomeEndIn 'tabs', moves to the first or last tabList
EnterSpaceIn 'tabs', shows the focused tab's step, validating the current one firstTrigger

Accessibility

What the parts set
PartSetsSo that
Listthe native ol; role="tablist" in 'tabs', with aria-orientation when verticalthe steps are read as a numbered list, or as tabs; a development build warns without a name
Itemthe native li; role="presentation" in 'tabs'the count of steps comes from the list
Triggera button with aria-current="step" on the current step in 'list', or role="tab" with aria-selected in 'tabs'; aria-disabled while unreachablethe current step is announced, and a step that cannot be reached says so
Titlethe Trigger's name, plus a visually hidden ", completed", ", error" or ", optional"a step's state is read with its name, not only shown by its color or icon
Descriptionthe Trigger's aria-describedbythe hint is read after the name
Indicatoraria-hiddenthe number and the check are not read twice
Separatoraria-hiddenthe line between steps is for sight
Panelrole="group" named by its Title, tabindex="-1"; a tabpanel in 'tabs'; hidden and inert while not currentfocus can move to the new step, and a hidden step's fields are out of reach
Rootone polite role="status" region after the stepper, always mounteda change of step is announced ("Step 2 of 3: Shipping"), since readers do not announce aria-current moving

Focus order

  1. In 'list' every step's button is a Tab stop, in the list's order, then the panel's fields, then Previous and Next.
  2. In 'tabs' the list is one Tab stop on the current step's tab; the arrow keys, Home and End move between tabs, and Enter or Space shows one.
  3. Previous and Next stay focusable at their bounds, with aria-disabled, so a reader does not lose their place.