Progress

Shows how much of a known task is done. Use it for uploads, imports, quota meters, and

Feedback Progress.jsx Progress.d.ts Progress.md

Playground

Change a prop and watch it. The code below follows along.

Install 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 label with showValue for operations over a few seconds. A bare bar tells the user something is happening but not what.
  • Tone is semantic. Use warning and danger for quota meters approaching a limit, not to decorate a normal upload.
  • For quota displays, max is 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.
TokenTierDeclared as
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-surface-actionsemanticvar(--dt-color-neutral-900)
--dt-surface-dangersemanticvar(--dt-color-red-600)
--dt-surface-successsemanticvar(--dt-color-green-600)
--dt-surface-sunkensemanticvar(--dt-color-neutral-100)
--dt-surface-warningsemanticvar(--dt-color-amber-500)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-font-weight-mediumprimitive500
--dt-motion-duration-normalnonenot declared
--dt-motion-easing-standardnonenot 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.