Steps
Named workflow stages expose the current task, confirmed progress and unavailable destinations.
On this page
Name your draft
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.
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.
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
| State | Meaning |
|---|---|
value matches the item id | This 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 omitted | Completion has not been asserted |
disabled | The stage cannot be selected; its description explains why |
readOnly | Present 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
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | – | Name of this navigation landmark. |
| itemsrequired | readonly StepItem[] | – | Workflow stages in order, with unique ids. |
| valuerequired | string | – | 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. |
| statusLabelsrequired | Readonly<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) | undefined | String | Display formatter for the one-based step number; defaults to ungrouped integer digits. |
| readOnly | boolean | undefined | false | Display progress without interactive navigation. |
| className | string | undefined | – | Additional root classes. |
| testId | string | undefined | – | Root test id. |
| ref | Ref<HTMLElement> | undefined | – | The navigation landmark. |
Details → Audience → Review
Opening Review marks every earlier stage complete.
Related
- 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.