Search for pages, actions, and quick links.
Goal bars, capacity meters and completion states. 6 blocks.
Several targets and how far along each one is.
src/components/blocks/progress/progress-goals.tsx"use client";
const GOALS = [
{ label: "Monthly revenue", current: 48295, target: 55000, unit: "$" },
{ label: "New customers", current: 847, target: 1000, unit: "" },
{ label: "Conversion rate", current: 6.2, target: 8, unit: "", suffix: "%" },
{ label: "Support CSAT", current: 4.6, target: 4.5, unit: "", suffix: "/5" },
];
/**
* Several targets and how far along each one is.
*
* The percentage is computed rather than stored, so the bar and the number can never disagree —
* the failure mode of a progress list is a hardcoded width beside a recalculated label.
*
* `aria-valuenow` on a `role="progressbar"` for each row, because the fill's width is a visual
* fact only; without it a screen reader gets the label and nothing else.
*/
export function ProgressGoals() {
return (
<ul className="space-y-4">
{GOALS.map((goal) => {
const pct = Math.min(100, Math.round((goal.current / goal.target) * 100));
const met = goal.current >= goal.target;
return (
<li key={goal.label} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-medium">{goal.label}</span>
<span className="tabular-nums text-muted-foreground">{pct}%</span>
</div>
<div
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`${goal.label}: ${pct}% of target`}
className="h-2 overflow-hidden rounded-full bg-muted"
>
<div
className={met ? "h-full rounded-full bg-success" : "h-full rounded-full bg-primary"}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-[11px] tabular-nums text-muted-foreground">
<span>
{goal.unit}
{goal.current.toLocaleString("en-US")}
{goal.suffix}
</span>
<span>
Target {goal.unit}
{goal.target.toLocaleString("en-US")}
{goal.suffix}
</span>
</div>
</li>
);
})}
</ul>
);
}Utilisation that can exceed 100%, and has to show it.
src/components/blocks/progress/progress-capacity.tsx"use client";
import { cn } from "@dashboardpack/core/lib/utils";
const SITES = [
{ name: "Rotterdam", used: 104 },
{ name: "Hamburg", used: 92 },
{ name: "Felixstowe", used: 78 },
{ name: "Gdansk", used: 54 },
];
/**
* Utilisation that can exceed 100%, and has to show it.
*
* The whole point of a capacity meter is the over-capacity case, so the track runs to 110 rather
* than 100: a bar clamped at full is indistinguishable from a bar at exactly full, which hides
* the one reading anyone needs to act on. The number is never clamped.
*/
export function ProgressCapacity() {
const SCALE = 110;
return (
<ul className="space-y-3">
{SITES.map((site) => {
const over = site.used > 100;
return (
<li key={site.name} className="space-y-1.5">
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="font-medium">{site.name}</span>
<span
className={cn(
"tabular-nums",
over ? "font-semibold text-destructive" : "text-muted-foreground",
)}
>
{site.used}%
</span>
</div>
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
{/* The 100% mark, so an over-capacity bar has something to be over. */}
<span
className="absolute inset-y-0 z-10 w-px bg-border"
style={{ insetInlineStart: `${(100 / SCALE) * 100}%` }}
aria-hidden="true"
/>
<div
className={cn("h-full rounded-full", over ? "bg-destructive" : "bg-primary")}
style={{ width: `${Math.min(100, (site.used / SCALE) * 100)}%` }}
/>
</div>
</li>
);
})}
</ul>
);
}Where something is in a fixed sequence.
src/components/blocks/progress/progress-steps.tsx"use client";
import { Check } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
const STEPS = ["Account", "Workspace", "Team", "Billing", "Done"];
/**
* Where something is in a fixed sequence.
*
* The connector is a flex sibling rather than an absolutely positioned line, so it stretches
* between steps at any width and needs no measurement. The current step carries `aria-current`,
* and the completed ones say so in text — a tick is invisible to a screen reader, and "step 3 of
* 5" is the only part of this component that actually conveys progress.
*/
export function ProgressSteps() {
const current = 2; // zero-based
return (
<ol className="flex items-center">
{STEPS.map((step, index) => {
const done = index < current;
const active = index === current;
return (
<li
key={step}
className={cn("flex items-center", index < STEPS.length - 1 && "flex-1")}
aria-current={active ? "step" : undefined}
>
<div className="flex flex-col items-center gap-1.5">
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums",
done && "border-primary bg-primary text-primary-foreground",
active && "border-primary text-primary",
!done && !active && "border-border text-muted-foreground",
)}
>
{done ? <Check className="size-3.5" aria-hidden="true" /> : index + 1}
</span>
<span
className={cn(
"hidden text-[11px] sm:block",
active ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{step}
</span>
<span className="sr-only">
{done ? "completed" : active ? `current, step ${index + 1} of ${STEPS.length}` : "not started"}
</span>
</div>
{index < STEPS.length - 1 && (
<span
className={cn("mx-2 h-px flex-1", done ? "bg-primary" : "bg-border")}
aria-hidden="true"
/>
)}
</li>
);
})}
</ol>
);
}The bar is computed from the boxes, so the two can never disagree.
src/components/blocks/progress/milestone-checklist.tsx"use client";
import { useState } from "react";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check } from "lucide-react";
/**
* A checklist whose progress bar is derived from the boxes, not tracked separately.
*
* Two sources of truth for "how complete is this" is how a checklist ends up showing 60% with four
* of five ticked. The percentage here is computed on every render from the items themselves, so they
* cannot disagree.
*
* The items are real `<input type="checkbox">` elements inside labels, not divs with click handlers.
* That gives Space to toggle, the correct role and checked state, and grouping of label to control —
* all of which a `role="checkbox"` div has to reimplement, usually incompletely.
*
* The completed label is struck through with `line-through` *and* the checkbox's own checked state
* carries the meaning, so the strike-through is decoration rather than the only signal.
*/
export function MilestoneChecklist() {
const [items, setItems] = useState([
{ id: "scope", label: "Scope signed off", done: true },
{ id: "design", label: "Design review complete", done: true },
{ id: "build", label: "Implementation merged", done: true },
{ id: "qa", label: "QA sign-off", done: false },
{ id: "launch", label: "Launch checklist cleared", done: false },
]);
const done = items.filter((item) => item.done).length;
const pct = Math.round((done / items.length) * 100);
return (
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-2">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">Release 3.0 readiness</p>
<p className="text-xs tabular-nums text-muted-foreground">
{done} of {items.length}
</p>
</div>
<div
role="progressbar"
aria-label="Release readiness"
aria-valuemin={0}
aria-valuemax={items.length}
aria-valuenow={done}
aria-valuetext={`${done} of ${items.length} complete`}
className="h-1.5 overflow-hidden rounded-full bg-muted"
>
<div
className={cn("h-full rounded-full transition-all", pct === 100 ? "bg-success" : "bg-primary")}
style={{ width: `${pct}%` }}
/>
</div>
</div>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id}>
<label className="flex cursor-pointer items-center gap-3 rounded-md p-1.5 transition-colors hover:bg-accent/40">
<input
type="checkbox"
checked={item.done}
onChange={() =>
setItems((prev) =>
prev.map((entry) =>
entry.id === item.id ? { ...entry, done: !entry.done } : entry,
),
)
}
className="peer sr-only"
/>
{/* The visual box. `peer-checked:` reads the real input's state, so the two can
never fall out of sync — and `peer-focus-visible:` keeps a visible focus ring
even though the input itself is sr-only.
The tick uses `peer-checked:[&>svg]:` rather than a bare `peer-checked:` on the
icon: peer variants only match SIBLINGS of the peer, and the icon is a
descendant of this span, so `peer-checked:opacity-100` on it would never
apply and the tick would stay invisible when checked. */}
<span
aria-hidden="true"
className="flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors [&>svg]:opacity-0 peer-checked:border-success peer-checked:bg-success peer-checked:text-success-foreground peer-checked:[&>svg]:opacity-100 peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2"
>
<Check className="h-3 w-3" />
</span>
<span
className={cn(
"text-sm",
item.done ? "text-muted-foreground line-through" : "text-foreground",
)}
>
{item.label}
</span>
</label>
</li>
))}
</ul>
</div>
);
}Tone follows the fraction consumed, and breaches say how far over.
src/components/blocks/progress/sla-breach-risk.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* Time-remaining bars where the colour is driven by risk, not by fill.
*
* A naive countdown bar goes red when it is nearly full, which is backwards for an SLA: a ticket at
* 90% of its window is the urgent one, and a ticket at 10% is fine. The tone here is chosen from the
* *fraction consumed* crossing named thresholds, so the mapping is explicit and readable rather than
* emergent.
*
* Breached rows are not clamped to 100% — they state how far over they are. Capping the display at
* "100%" hides the difference between one minute late and two days late, which is the only thing
* anyone triaging wants to know.
*
* The tone map is a lookup keyed by a computed level, not nested ternaries in the className. That
* keeps the thresholds in one place where they can be read and changed together.
*/
export function SlaBreachRisk() {
const tickets = [
{ id: "SUP-4821", subject: "Checkout returns 502", consumed: 0.28, left: "17h left" },
{ id: "SUP-4818", subject: "Duty charged twice", consumed: 0.71, left: "6h left" },
{ id: "SUP-4802", subject: "Cannot export invoices", consumed: 0.94, left: "1h left" },
{ id: "SUP-4791", subject: "SSO login loop", consumed: 1.4, left: "9h overdue" },
];
const tones = {
ok: { bar: "bg-success", badge: "success" as const, label: "On track" },
warn: { bar: "bg-warning", badge: "warning" as const, label: "At risk" },
breach: { bar: "bg-destructive", badge: "destructive" as const, label: "Breached" },
};
const levelFor = (consumed: number) =>
consumed >= 1 ? "breach" : consumed >= 0.6 ? "warn" : "ok";
return (
<ul className="divide-y rounded-lg border">
{tickets.map((ticket) => {
const tone = tones[levelFor(ticket.consumed)];
return (
<li key={ticket.id} className="space-y-2 p-3">
<div className="flex flex-wrap items-baseline gap-2">
<span className="font-mono text-xs text-muted-foreground">{ticket.id}</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{ticket.subject}</span>
<Badge variant={tone.badge} className="text-[10px]">
{tone.label}
</Badge>
</div>
<div className="flex items-center gap-3">
<div
role="progressbar"
aria-label={`SLA window consumed for ${ticket.id}`}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(ticket.consumed * 100)}
aria-valuetext={`${Math.round(ticket.consumed * 100)}% of the window used, ${ticket.left}`}
className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"
>
<div
className={cn("h-full rounded-full", tone.bar)}
// The BAR is capped so it cannot overflow its track; the label below is not,
// because how far past the deadline something is matters.
style={{ width: `${Math.min(100, ticket.consumed * 100)}%` }}
/>
</div>
<span className="w-24 shrink-0 text-end text-xs tabular-nums text-muted-foreground">
{ticket.left}
</span>
</div>
</li>
);
})}
</ul>
);
}An ordered list with a connector that mirrors under RTL.
src/components/blocks/progress/onboarding-stepper.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { cn } from "@dashboardpack/core/lib/utils";
import { Check } from "lucide-react";
/**
* A horizontal stepper as an ordered list, with the connector as a pseudo-element.
*
* `<ol>` gives "3 of 5" for free; a flex row of divs with borders between them conveys the sequence
* to sighted users only. The current step carries `aria-current="step"`, which is what lets
* assistive tech report position rather than leaving it to a ring colour.
*
* The connector is positioned with `start-1/2` and `end-*` logical properties, so the whole stepper
* flows the other way under RTL with no JavaScript and no separate stylesheet.
*/
export function OnboardingStepper() {
const steps = ["Account", "Organisation", "Billing", "Team", "Done"];
const [current, setCurrent] = useState(2);
return (
<div className="space-y-6">
<ol className="flex">
{steps.map((step, index) => {
const done = index < current;
const active = index === current;
return (
<li key={step} className="relative flex flex-1 flex-col items-center gap-2">
{/* Connector to the next step. Logical inset, so it mirrors under RTL. */}
{index < steps.length - 1 && (
<span
aria-hidden="true"
// start-1/2 + w-full spans from this step's centre to the next one's, since
// every <li> is flex-1 and so one <li> wide.
className={cn(
"absolute top-4 h-0.5 w-full -translate-y-1/2 start-1/2",
done ? "bg-success" : "bg-border",
)}
/>
)}
<span
className={cn(
"z-10 flex h-8 w-8 items-center justify-center rounded-full border-2 bg-background text-xs font-semibold tabular-nums",
done && "border-success bg-success text-success-foreground",
active && "border-primary text-primary",
!done && !active && "border-border text-muted-foreground",
)}
>
{done ? <Check className="h-4 w-4" aria-hidden="true" /> : index + 1}
</span>
<span
aria-current={active ? "step" : undefined}
className={cn(
"text-center text-xs",
active ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
{step}
</span>
</li>
);
})}
</ol>
<div className="flex justify-between gap-2">
<Button
variant="outline"
size="sm"
disabled={current === 0}
onClick={() => setCurrent((step) => Math.max(0, step - 1))}
>
Back
</Button>
<Button
size="sm"
disabled={current === steps.length - 1}
onClick={() => setCurrent((step) => Math.min(steps.length - 1, step + 1))}
>
Continue
</Button>
</div>
</div>
);
}