Feedback
Steps progress
One bar cut into phases, each with its own fill and label, for jobs that run in stages: upload, scan, publish.
- Built on
role=progressbarContainer queries- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 2.3 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { StepsProgress } from "@/components/equip/steps-progress";
export default function Demo() {
return (
<StepsProgress
label="Publishing brand-kit.zip"
steps={["Upload", "Scan", "Publish"]}
current={1}
value={40}
activity="Scanning"
style={{ width: 208 }}
/>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div class="eq-steps-progress" data-density="few" style="--_count:3;width:208px">
<div class="eq-steps-progress-bar" role="progressbar" aria-label="Publishing brand-kit.zip" aria-valuemin="0" aria-valuemax="100" aria-valuenow="47" aria-valuetext="Scanning, step 2 of 3, 40%">
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="current" style="--_fill:40">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="upcoming" style="--_fill:0">
<span class="eq-steps-progress-fill"></span>
</span>
</div>
<ol class="eq-steps-progress-labels" aria-hidden="true">
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Upload</span>
</li>
<li class="eq-steps-progress-step" data-status="current">
<span class="eq-steps-progress-name">Scan</span>
</li>
<li class="eq-steps-progress-step" data-status="upcoming">
<span class="eq-steps-progress-name">Publish</span>
</li>
</ol>
<p class="eq-steps-progress-summary" aria-hidden="true">Step 2 of 3: Scan</p>
</div>Examples
Four phases with a heading
aria-valuenow is the whole job, 69 here; aria-valuetext says the phase and its own percent: "Running migrations, step 3 of 4, 75%".
Code
import { StepsProgress } from "@/components/equip/steps-progress";
export default function Example() {
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 400 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, fontSize: 13 }}>
<strong>Deploying api to eu-west-1</strong>
<span style={{ color: "var(--eq-ink-2)" }}>About 1 min left</span>
</div>
<StepsProgress
label="Deploying api to eu-west-1"
steps={["Build", "Push image", "Migrate", "Roll out"]}
current={2}
value={75}
activity="Running migrations"
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:8px;width:100%;max-width:400px">
<div style="display:flex;justify-content:space-between;gap:12px;font-size:13px">
<strong>Deploying api to eu-west-1</strong>
<span style="color:var(--eq-ink-2)">About 1 min left</span>
</div>
<div class="eq-steps-progress" data-density="some" style="--_count:4">
<div class="eq-steps-progress-bar" role="progressbar" aria-label="Deploying api to eu-west-1" aria-valuemin="0" aria-valuemax="100" aria-valuenow="69" aria-valuetext="Running migrations, step 3 of 4, 75%">
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="current" style="--_fill:75">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="upcoming" style="--_fill:0">
<span class="eq-steps-progress-fill"></span>
</span>
</div>
<ol class="eq-steps-progress-labels" aria-hidden="true">
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Build</span>
</li>
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Push image</span>
</li>
<li class="eq-steps-progress-step" data-status="current">
<span class="eq-steps-progress-name">Migrate</span>
</li>
<li class="eq-steps-progress-step" data-status="upcoming">
<span class="eq-steps-progress-name">Roll out</span>
</li>
</ol>
<p class="eq-steps-progress-summary" aria-hidden="true">Step 3 of 4: Migrate</p>
</div>
</div>A failed phase
The failed segment fills in the danger tone and its label gains a cross, so colour is not the only cue; the spoken text says it failed.
Code
import { StepsProgress } from "@/components/equip/steps-progress";
export default function Example() {
return (
<StepsProgress
label="Importing customers.csv"
steps={["Upload", "Validate", "Import"]}
current={1}
failed
style={{ width: "100%", maxWidth: 320 }}
/>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-steps-progress" data-density="few" style="--_count:3;width:100%;max-width:320px">
<div class="eq-steps-progress-bar" role="progressbar" aria-label="Importing customers.csv" aria-valuemin="0" aria-valuemax="100" aria-valuenow="33" aria-valuetext="Validate failed, step 2 of 3">
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="failed" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="upcoming" style="--_fill:0">
<span class="eq-steps-progress-fill"></span>
</span>
</div>
<ol class="eq-steps-progress-labels" aria-hidden="true">
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Upload</span>
</li>
<li class="eq-steps-progress-step" data-status="failed">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
</svg>
<span class="eq-steps-progress-name">Validate</span>
</li>
<li class="eq-steps-progress-step" data-status="upcoming">
<span class="eq-steps-progress-name">Import</span>
</li>
</ol>
<p class="eq-steps-progress-summary" aria-hidden="true" data-failed="">Step 2 of 3: Validate failed</p>
</div>Collapsing when narrow
A container query swaps the labels for one line when the bar is too narrow for them, so nothing overflows at any width.
Code
import { StepsProgress } from "@/components/equip/steps-progress";
export default function Example() {
return (
<div style={{ display: "grid", gap: 24, width: "100%", maxWidth: 480 }}>
<StepsProgress
label="Onboarding Lumen Studio"
steps={["Account", "Workspace", "Billing", "Invite", "Import"]}
current={3}
value={20}
activity="Inviting teammates"
/>
<StepsProgress
label="Onboarding Lumen Studio"
steps={["Account", "Workspace", "Billing", "Invite", "Import"]}
current={3}
value={20}
activity="Inviting teammates"
style={{ width: 200 }}
/>
</div>
);
}HTMLworks in any framework, with the part's CSS
<div style="display:grid;gap:24px;width:100%;max-width:480px">
<div class="eq-steps-progress" data-density="some" style="--_count:5">
<div class="eq-steps-progress-bar" role="progressbar" aria-label="Onboarding Lumen Studio" aria-valuemin="0" aria-valuemax="100" aria-valuenow="64" aria-valuetext="Inviting teammates, step 4 of 5, 20%">
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="current" style="--_fill:20">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="upcoming" style="--_fill:0">
<span class="eq-steps-progress-fill"></span>
</span>
</div>
<ol class="eq-steps-progress-labels" aria-hidden="true">
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Account</span>
</li>
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Workspace</span>
</li>
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Billing</span>
</li>
<li class="eq-steps-progress-step" data-status="current">
<span class="eq-steps-progress-name">Invite</span>
</li>
<li class="eq-steps-progress-step" data-status="upcoming">
<span class="eq-steps-progress-name">Import</span>
</li>
</ol>
<p class="eq-steps-progress-summary" aria-hidden="true">Step 4 of 5: Invite</p>
</div>
<div class="eq-steps-progress" data-density="some" style="--_count:5;width:200px">
<div class="eq-steps-progress-bar" role="progressbar" aria-label="Onboarding Lumen Studio" aria-valuemin="0" aria-valuemax="100" aria-valuenow="64" aria-valuetext="Inviting teammates, step 4 of 5, 20%">
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="done" style="--_fill:100">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="current" style="--_fill:20">
<span class="eq-steps-progress-fill"></span>
</span>
<span class="eq-steps-progress-segment" data-status="upcoming" style="--_fill:0">
<span class="eq-steps-progress-fill"></span>
</span>
</div>
<ol class="eq-steps-progress-labels" aria-hidden="true">
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Account</span>
</li>
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Workspace</span>
</li>
<li class="eq-steps-progress-step" data-status="done">
<svg class="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
<span class="eq-steps-progress-name">Billing</span>
</li>
<li class="eq-steps-progress-step" data-status="current">
<span class="eq-steps-progress-name">Invite</span>
</li>
<li class="eq-steps-progress-step" data-status="upcoming">
<span class="eq-steps-progress-name">Import</span>
</li>
</ol>
<p class="eq-steps-progress-summary" aria-hidden="true">Step 4 of 5: Invite</p>
</div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | The accessible name for the whole job, such as "Publishing release 4.2". |
steps | string[] | none | The phases in order, a word or two each. |
current | number | none | Index of the phase under way. Phases before it are done; steps.length means the job is complete. |
value | number | 0 | How far the current phase has got, 0 to 100. Spoken as the phase's own percent; aria-valuenow is the whole job. |
activity | string | none | What the current phase is doing, such as "Scanning". Defaults to the step's name. |
failed | boolean | false | Marks the current phase as failed. |
...rest | ComponentProps<"div"> | none | Every native div attribute, on the root. |
Anatomy
| root | .eq-steps-progress | A size container; data-density is few, some or many. |
|---|---|---|
| bar | .eq-steps-progress-bar | The one role="progressbar". |
| segment | .eq-steps-progress-segment | data-status is done, current, upcoming or failed. |
| fill | .eq-steps-progress-fill | |
| labels | .eq-steps-progress-labels | An ordered list, one label under each segment. |
| step | .eq-steps-progress-step | |
| mark | .eq-steps-progress-mark | A tick on done steps, a cross on a failed one. |
| name | .eq-steps-progress-name | |
| summary | .eq-steps-progress-summary | "Step 2 of 3: Scan", shown instead of the labels when narrow. |
Notes
- Distinct from Progress, which is one bar for one task, and from Stepper, which is navigation through steps a person takes and links back to them; this reports a job the system runs.
- There is one progressbar for the whole job; its aria-valuetext reads like "Scanning, step 2 of 3, 40%", or "Scan failed, step 2 of 3".
- The labels and summary are hidden from assistive tech because the value text already says the same.
- A failed phase fills in the danger tone and gains a cross, so colour is never the only cue.
- Labels sit under their segments and wrap; when the bar is too narrow for them a container query swaps in the one-line summary, and six or more steps always use it.
- It is not a live region. If finishing or failing matters, announce it in a status message as well.
- Renders on the server with no client JavaScript.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/steps-progress.tsx112 lines
import type { ComponentProps, CSSProperties } from "react";
import "./steps-progress.css";
export type StepsProgressProps = Omit<ComponentProps<"div">, "children"> & {
/** Names the job for screen readers, such as "Publishing release 4.2". */
label: string;
/** The phases in order, one or two words each: "Upload", "Scan", "Publish". */
steps: string[];
/** Index of the phase under way. Phases before it are done; steps.length means all done. */
current: number;
/** How far the current phase has got, 0 to 100. Spoken as the step's own percent; aria-valuenow is the whole job. */
value?: number;
/** Says what the current phase is doing, such as "Scanning". Defaults to the step's name. */
activity?: string;
/** Marks the current phase as failed. */
failed?: boolean;
};
type Status = "done" | "current" | "upcoming" | "failed";
export function StepsProgress({
label,
steps,
current,
value = 0,
activity,
failed = false,
className,
style,
...rest
}: StepsProgressProps) {
const count = steps.length;
const at = Math.min(Math.max(Math.floor(current), 0), count);
const finished = at >= count;
const stepValue = finished ? 100 : Math.min(Math.max(value, 0), 100);
const overall = count === 0 ? 0 : Math.round(((Math.min(at, count) + (finished ? 0 : stepValue / 100)) / count) * 100);
const name = finished ? "" : steps[at] ?? "";
const doing = activity ?? name;
const valueText = count === 0
? "No steps"
: finished
? `Complete, ${count} of ${count} steps done`
: failed
? `${name} failed, step ${at + 1} of ${count}`
: `${doing}, step ${at + 1} of ${count}, ${Math.round(stepValue)}%`;
const summary = finished
? "All steps done"
: failed
? `Step ${at + 1} of ${count}: ${name} failed`
: `Step ${at + 1} of ${count}: ${name}`;
const density = count <= 3 ? "few" : count <= 5 ? "some" : "many";
return (
<div
{...rest}
className={["eq-steps-progress", className].filter(Boolean).join(" ")}
data-density={density}
style={{ "--_count": Math.max(count, 1), ...style } as CSSProperties}
>
<div
className="eq-steps-progress-bar"
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={overall}
aria-valuetext={valueText}
>
{steps.map((step, i) => {
const status: Status = i < at ? "done" : i === at ? (failed ? "failed" : "current") : "upcoming";
const fill = status === "done" || status === "failed" ? 100 : status === "current" ? stepValue : 0;
return (
<span
key={`${i}-${step}`}
className="eq-steps-progress-segment"
data-status={status}
style={{ "--_fill": fill } as CSSProperties}
>
<span className="eq-steps-progress-fill" />
</span>
);
})}
</div>
<ol className="eq-steps-progress-labels" aria-hidden="true">
{steps.map((step, i) => {
const status: Status = i < at ? "done" : i === at ? (failed ? "failed" : "current") : "upcoming";
return (
<li key={`${i}-${step}`} className="eq-steps-progress-step" data-status={status}>
{status === "done" && (
<svg className="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2.5 6.5l2.25 2.25L9.5 3.5" />
</svg>
)}
{status === "failed" && (
<svg className="eq-steps-progress-mark" viewBox="0 0 12 12" aria-hidden="true">
<path d="M3.5 3.5l5 5M8.5 3.5l-5 5" />
</svg>
)}
<span className="eq-steps-progress-name">{step}</span>
</li>
);
})}
</ol>
<p className="eq-steps-progress-summary" aria-hidden="true" data-failed={failed && !finished ? "" : undefined}>
{summary}
</p>
</div>
);
}components/equip/steps-progress.css132 lines
@layer equip {
.eq-steps-progress {
container: eq-steps-progress / inline-size;
display: grid;
gap: var(--eq-space-2);
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-xs) / 1.35 var(--eq-font);
}
/* One bar cut into equal segments, each with its own fill. */
.eq-steps-progress-bar {
display: grid;
grid-template-columns: repeat(var(--_count), minmax(0, 1fr));
gap: 3px;
}
.eq-steps-progress-segment {
display: block;
height: 6px;
border-radius: var(--eq-radius-full);
background: var(--eq-tint-strong);
overflow: hidden;
}
.eq-steps-progress-fill {
display: block;
width: calc(var(--_fill, 0) * 1%);
height: 100%;
border-radius: inherit;
background: var(--eq-solid);
/* A measurement: it travels, but never overshoots the value it reports. */
transition:
width var(--eq-dur-move) var(--eq-ease),
background-color var(--eq-dur) var(--eq-ease);
}
.eq-steps-progress-segment[data-status="failed"] .eq-steps-progress-fill {
background: var(--eq-danger);
}
.eq-steps-progress-labels {
display: grid;
grid-template-columns: repeat(var(--_count), minmax(0, 1fr));
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-steps-progress-step {
display: flex;
align-items: flex-start;
gap: 2px;
min-width: 0;
color: var(--eq-ink-3);
transition: color var(--eq-dur) var(--eq-ease);
}
.eq-steps-progress-step[data-status="done"] {
color: var(--eq-ink-2);
}
.eq-steps-progress-step[data-status="current"] {
color: var(--eq-ink);
font-weight: var(--eq-weight-strong);
}
.eq-steps-progress-step[data-status="failed"] {
color: var(--eq-danger);
font-weight: var(--eq-weight-strong);
}
.eq-steps-progress-name {
min-width: 0;
overflow-wrap: anywhere;
}
.eq-steps-progress-mark {
flex: none;
width: 12px;
height: 12px;
margin-top: 0.1em;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Takes over from the labels when there is too little room for one label per segment. */
.eq-steps-progress-summary {
display: none;
min-width: 0;
margin: 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
overflow-wrap: anywhere;
}
.eq-steps-progress-summary[data-failed] {
color: var(--eq-danger);
}
.eq-steps-progress[data-density="many"] .eq-steps-progress-labels {
display: none;
}
.eq-steps-progress[data-density="many"] .eq-steps-progress-summary {
display: block;
}
@container eq-steps-progress (width < 180px) {
.eq-steps-progress[data-density="few"] .eq-steps-progress-labels {
display: none;
}
.eq-steps-progress[data-density="few"] .eq-steps-progress-summary {
display: block;
}
}
@container eq-steps-progress (width < 320px) {
.eq-steps-progress[data-density="some"] .eq-steps-progress-labels {
display: none;
}
.eq-steps-progress[data-density="some"] .eq-steps-progress-summary {
display: block;
}
}
}