Skip to content
design
Components

Steps

Named workflow stages expose the current task, confirmed progress and unavailable destinations.

On this page

Name your draft

Prepare a local report draft, revisit completed stages and keep the entered answers.
Code
import { Steps, type StepItem, type StepStatus } from "@bosunkit/components/steps";
import { WizardStep, WizardFinal } from "@bosunkit/components/wizard-step";
import { FormField } from "@bosunkit/components/form-field";
import { Input } from "@bosunkit/components/input";
import {
  useEffect,
  useReducer,
  useRef,
  type ActionDispatch,
  type ChangeEvent,
  type ReactElement,
  type RefObject,
} from "react";
export const description: string =
  "A three-stage local draft workflow: validation gates the next stage, completed stages can be revisited, and answers survive navigation. Nothing is published.";
type Stage = "details" | "audience" | "review" | "done";
interface Flow {
  stage: Stage;
  title: string;
  audience: string;
  reached: number;
  error: boolean;
}
type Event =
  { type: "title" | "audience"; value: string } | { type: "next" } | { type: "go"; stage: Stage } | { type: "reset" };
const initial: Flow = { stage: "details", title: "", audience: "Engineering team", reached: 0, error: false };
function reduce(state: Flow, event: Event): Flow {
  switch (event.type) {
    case "title":
      return { ...state, title: event.value, error: false };
    case "audience":
      return { ...state, audience: event.value, error: false };
    case "reset":
      return initial;
    case "go":
      return { ...state, stage: event.stage, error: false };
    case "next":
      if (
        (state.stage === "details" && state.title.trim() === "") ||
        (state.stage === "audience" && state.audience.trim() === "")
      )
        return { ...state, error: true };
      return {
        ...state,
        stage: state.stage === "details" ? "audience" : state.stage === "audience" ? "review" : "done",
        reached: Math.max(state.reached, state.stage === "details" ? 1 : 2),
        error: false,
      };
  }
}
const statuses: Readonly<Record<StepStatus, string>> = {
  upcoming: "To do",
  current: "Current stage",
  complete: "Complete",
  error: "Needs a value",
};
export default function Showcase(): ReactElement {
  const [flow, dispatch]: [Flow, ActionDispatch<[Event]>] = useReducer(reduce, initial);
  const field: RefObject<HTMLInputElement | null> = useRef<HTMLInputElement | null>(null);
  const panel: RefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
  const previousStage: RefObject<Stage> = useRef<Stage>(initial.stage);
  useEffect((): void => {
    if (previousStage.current !== flow.stage || flow.error) (field.current ?? panel.current)?.focus();
    previousStage.current = flow.stage;
  }, [flow.stage, flow.error]);
  const stages: readonly StepItem[] = [
    {
      id: "details",
      label: "Details",
      description: "Name the draft",
      status:
        flow.stage === "details" && flow.error
          ? "error"
          : flow.title.trim() !== "" && flow.reached >= 1
            ? "complete"
            : "upcoming",
    },
    {
      id: "audience",
      label: "Audience",
      description: "Choose the reader",
      disabled: flow.reached < 1 || flow.title.trim() === "",
      status:
        flow.stage === "audience" && flow.error
          ? "error"
          : flow.audience.trim() !== "" && flow.reached >= 2
            ? "complete"
            : "upcoming",
    },
    {
      id: "review",
      label: "Review",
      description: "Check the summary",
      disabled: flow.reached < 2 || flow.title.trim() === "" || flow.audience.trim() === "",
      status: flow.stage === "done" ? "complete" : "upcoming",
    },
  ];
  function go(value: string): void {
    if (value === "details" || value === "audience" || value === "review") dispatch({ type: "go", stage: value });
  }
  return (
    <div style={{ display: "grid", gap: "var(--space-lg)", width: "100%" }}>
      <Steps
        label="Draft preparation"
        items={stages}
        value={flow.stage}
        onValueChange={go}
        statusLabels={statuses}
        readOnly={flow.stage === "done"}
      />
      <div ref={panel} tabIndex={-1}>
        {flow.stage === "done" ? (
          <WizardFinal
            title="Local draft prepared"
            description="This example has not saved or published a report."
            doneLabel="Start again"
            onDone={(): void => {
              dispatch({ type: "reset" });
            }}
          />
        ) : (
          <WizardStep
            title={
              flow.stage === "details"
                ? "Name your draft"
                : flow.stage === "audience"
                  ? "Who will read it?"
                  : "Review your draft"
            }
            nextLabel={flow.stage === "review" ? "Finish local example" : "Continue"}
            onNext={(): void => {
              dispatch({ type: "next" });
            }}
            previousLabel={flow.stage === "details" ? undefined : "Back"}
            onPrevious={
              flow.stage === "details"
                ? undefined
                : (): void => {
                    dispatch({ type: "go", stage: flow.stage === "review" ? "audience" : "details" });
                  }
            }
          >
            {flow.stage === "details" ? (
              <FormField
                label="Draft title"
                required
                error={flow.error ? "Enter a title before continuing." : undefined}
              >
                <Input
                  ref={field}
                  value={flow.title}
                  onChange={(event: ChangeEvent<HTMLInputElement>): void => {
                    dispatch({ type: "title", value: event.currentTarget.value });
                  }}
                />
              </FormField>
            ) : flow.stage === "audience" ? (
              <FormField
                label="Intended audience"
                required
                error={flow.error ? "Name the audience before continuing." : undefined}
              >
                <Input
                  ref={field}
                  value={flow.audience}
                  onChange={(event: ChangeEvent<HTMLInputElement>): void => {
                    dispatch({ type: "audience", value: event.currentTarget.value });
                  }}
                />
              </FormField>
            ) : (
              <dl>
                <dt>Title</dt>
                <dd>{flow.title}</dd>
                <dt>Audience</dt>
                <dd>{flow.audience}</dd>
              </dl>
            )}
          </WizardStep>
        )}
      </div>
    </div>
  );
}

Steps is a navigation landmark containing an ordered list of workflow stages. value identifies the current stage and onValueChange requests another. The caller decides which stages are available and whether their work is complete. A stage's position never implies that its data has been saved or validated.

01Name the stagesUse a few short labels that describe meaningful parts of the task. Keep the detailed instructions inside each stage.
02Make progress explicitPass completion and validation states from the workflow. A later current stage does not prove earlier work is complete.
03Keep answers when revisitingOwn drafts above the individual panels, and explain why a stage cannot be entered.

Examples

Vertical stages and explicit states

An error retains its error mark and text even when it is the current stage. Current location is independently exposed with aria-current="step". Read-only mode keeps that context while removing navigation buttons.

A vertical review with completed, current, invalid and unavailable stages. Read-only mode presents the same evidence without navigation controls.
Code
import { Steps, type StepItem, type StepStatus } from "@bosunkit/components/steps";
import { Switch } from "@bosunkit/components/switch";
import { useState, type Dispatch, type ReactElement, type SetStateAction } from "react";
export const description: string =
  "A vertical review with completed, current, invalid and unavailable stages. Read-only mode presents the same evidence without navigation controls.";
const statuses: Readonly<Record<StepStatus, string>> = {
  upcoming: "Not started",
  current: "Current stage",
  complete: "Complete",
  error: "Needs attention",
};
const items: readonly StepItem[] = [
  { id: "sources", label: "Sources", status: "complete", description: "References collected" },
  { id: "findings", label: "Findings", status: "error", description: "Add a supporting source" },
  { id: "summary", label: "Summary", description: "Write after checking findings", disabled: true },
];
export default function States(): ReactElement {
  const [value, setValue]: [string, Dispatch<SetStateAction<string>>] = useState("findings");
  const [readOnly, setReadOnly]: [boolean, Dispatch<SetStateAction<boolean>>] = useState(false);
  return (
    <div style={{ display: "grid", gap: "var(--space-lg)" }}>
      <label style={{ display: "flex", alignItems: "center", gap: "var(--space-sm)" }}>
        <Switch checked={readOnly} onCheckedChange={setReadOnly} />
        Read-only progress
      </label>
      <Steps
        label="Technical report review"
        items={items}
        value={value}
        onValueChange={setValue}
        statusLabels={statuses}
        orientation="vertical"
        readOnly={readOnly}
      />
    </div>
  );
}

State and availability

StateMeaning
value matches the item idThis is the displayed stage
status="complete"The caller has confirmed completion
status="error"The stage has an unresolved issue described in words
status="upcoming" or omittedCompletion has not been asserted
disabledThe stage cannot be selected; its description explains why
readOnlyPresent the progress as information without navigation controls

All four status labels come from the caller. A current non-error stage uses the current label even if its underlying work is complete. Complete and error marks are distinct shapes, and visible status text accompanies them. Workflow errors use ordinary interface presentation, not maritime alert priority colors.

Workflow ownership

Use a reducer or route state for the task. Store draft answers outside panels that unmount. Validate the current stage before enabling forward navigation; invalidate downstream completion when an earlier answer changes. Treat a selection as a request, and only update value after any required checks succeed.

Steps renders no content panels and performs no save. Pair it with Wizard step for the body and its Back/Continue actions. An application can also use it above route-owned panels. Keep a single visible progress overview rather than repeating two progress tracks for the same task.

Keyboard, focus and layout

Available stages are ordinary buttons reached with Tab and selected with Enter or Space. Arrow keys are not repurposed as tab navigation. The caller should focus the next stage's heading or first field when a substantial panel change needs context, and associate validation messages with their fields.

orientation="auto" uses a horizontal row and switches to a vertical list in a phone-width bk-screen container. The component also switches to a vertical list in a narrow content column, even without a screen container. horizontal and vertical fix the arrangement. Long labels wrap. Use a vertical arrangement when descriptions are substantial; consider splitting a task whose stage list becomes difficult to scan.

Props

PropTypeDefaultDescription
labelrequiredstring–Name of this navigation landmark.
itemsrequiredreadonly StepItem[]–Workflow stages in order, with unique ids.
valuerequiredstring–Id of the stage currently displayed; no implicit completion follows from it.
onValueChangerequired(value: string) => void–Requests a stage change. The caller owns validation, saved answers and permission to proceed.
statusLabelsrequiredReadonly<Record<StepStatus, string>>–Translated state labels, shown as text alongside the marks.
orientation"horizontal" | "vertical" | "auto" | undefined"auto"Horizontal by default; auto changes to a vertical list in a narrow screen container.
formatStep((number: number) => string) | undefinedStringDisplay formatter for the one-based step number; defaults to ungrouped integer digits.
readOnlyboolean | undefinedfalseDisplay progress without interactive navigation.
classNamestring | undefined–Additional root classes.
testIdstring | undefined–Root test id.
refRef<HTMLElement> | undefined–The navigation landmark.
Do

Details → Audience → Review

Describe real stages and keep their completion explicit.
Don't

Opening Review marks every earlier stage complete.

Do not imply completion simply because another stage is on screen.
  • Navigation: task progress compared with route and collection navigation.
  • Wizard step: stage content and actions.
  • Form field: labels and validation.
  • Tabs: peer views without an ordered workflow.

Usage reference: Atlassian progress tracker.