Progress
Shows how much of a known task is done. Use it for uploads, imports, quota meters, and
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
Progress is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Progress } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
Shows how much of a known task is done. Use it for uploads, imports, quota meters, and multi-file operations.
Rules
- Only use a determinate bar when you can measure progress honestly. A bar that sits at 90% for a minute costs more trust than a spinner.
- Pair
labelwithshowValuefor operations over a few seconds. A bare bar tells the user something is happening but not what. - Tone is semantic. Use
warninganddangerfor quota meters approaching a limit, not to decorate a normal upload. - For quota displays,
maxis the limit and the label states the units.
Tradeoffs
Indeterminate bars look like progress without being progress. If you cannot measure, Spinner is the more honest control.
Props
The typed contract, from Progress.d.ts.
import * as React from "react";
/** Determinate or indeterminate progress bar. */
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
/** Current value. Omit for an indeterminate bar. */
value?: number;
/** @default 100 */
max?: number;
/** Visible label above the bar. Also the accessible name. */
label?: React.ReactNode;
/** Show the percentage on the right. @default false */
showValue?: boolean;
/** @default "primary" */
tone?: "primary" | "success" | "warning" | "danger";
/** @default "md" */
size?: "sm" | "md" | "lg";
}
export declare function Progress(props: ProgressProps): React.JSX.Element;
Tokens it reads
Every custom property Progress.jsx resolves, read from the source: 11 semantic, 1 primitive, 2 undeclared. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
- primitive Raw values. A component reading one directly is a deliberate exception.
- none Referenced by the source but declared by no tier, so it resolves to nothing.
| Token | Tier | Declared as |
|---|---|---|
--dt-radius-pill | semantic | var(--dt-radius-raw-full) |
--dt-space-inline-sm | semantic | var(--dt-dim-3) |
--dt-space-stack-2xs | semantic | var(--dt-dim-1) |
--dt-surface-action | semantic | var(--dt-color-neutral-900) |
--dt-surface-danger | semantic | var(--dt-color-red-600) |
--dt-surface-success | semantic | var(--dt-color-green-600) |
--dt-surface-sunken | semantic | var(--dt-color-neutral-100) |
--dt-surface-warning | semantic | var(--dt-color-amber-500) |
--dt-text-label-sm-family | semantic | var(--dt-font-family-secondary) |
--dt-text-label-sm-size | semantic | var(--dt-font-size-xs) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
--dt-font-weight-medium | primitive | 500 |
--dt-motion-duration-normal | none | not declared |
--dt-motion-easing-standard | none | not declared |
Source
React only, no dependencies, every value a token.
import React from "react";
export function Progress({ value, max = 100, label, showValue = false, tone = "primary", size = "md", style, ...rest }) {
const indeterminate = value == null;
const pct = indeterminate ? 0 : Math.min(100, Math.max(0, (value / max) * 100));
const h = size === "sm" ? 4 : size === "lg" ? 10 : 6;
const fill = { primary: "var(--dt-surface-action)", success: "var(--dt-surface-success)", warning: "var(--dt-surface-warning)", danger: "var(--dt-surface-danger)" }[tone];
return (
<div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)", ...style }} {...rest}>
{(label || showValue) && (
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--dt-space-inline-sm)" }}>
{label && <span style={{ fontFamily: "var(--dt-text-label-sm-family)", fontSize: "var(--dt-text-label-sm-size)", color: "var(--dt-text-secondary)", fontWeight: "var(--dt-font-weight-medium)" }}>{label}</span>}
{showValue && !indeterminate && <span style={{ fontFamily: "var(--dt-text-label-sm-family)", fontSize: "var(--dt-text-label-sm-size)", color: "var(--dt-text-secondary)", fontVariantNumeric: "tabular-nums" }}>{Math.round(pct)}%</span>}
</div>
)}
<div
role="progressbar"
aria-label={typeof label === "string" ? label : undefined}
aria-valuenow={indeterminate ? undefined : value}
aria-valuemin={0}
aria-valuemax={max}
style={{ height: h, background: "var(--dt-surface-sunken)", borderRadius: "var(--dt-radius-pill)", overflow: "hidden" }}
>
<div style={{
height: "100%", background: fill, borderRadius: "var(--dt-radius-pill)",
width: indeterminate ? "35%" : pct + "%",
transition: "width var(--dt-motion-duration-normal) var(--dt-motion-easing-standard)",
animation: indeterminate ? "dt-progress-slide 1200ms var(--dt-motion-easing-standard) infinite" : undefined,
}} />
</div>
</div>
);
}
In context
Progress also appears on the feedback reference cards, alongside the rest of its family.