# Progress bar

A track with a fill showing how far something has got, with an optional label and value above it; it can say the work failed, or that its end is not known.

Block: `smbk-progress`. Status: candidate. Figma: the Figma file, https://www.figma.com/community/file/1688324308358870630. Desktop and mobile: differ by presentation only.

## Options

| Axis | Values | Default | Meaning |
|---|---|---|---|
| `tone` | `default`, `brand`, `success`, `warning`, `danger` | `brand` | What fills the track. A failed bar is always danger. |
| `size` | `md`, `sm` | `md` | Small uses the slim track and the smaller label styles, for dense rows and lists. |
| `type` | `default`, `pixel` | `default` | Default is one smooth bar. Pixel cuts the track into twenty segments and fills whole ones. |
| `state` | `default`, `failed`, `unknown` | `default` | Failed leads with the danger icon and words and offers to try again. Unknown shows a detached part of the track and no number, for work whose end is not known. |

Parts: `smbk-progress__header`, `smbk-progress__label`, `smbk-progress__icon`, `smbk-progress__value`, `smbk-progress__retry`, `smbk-progress__track`, `smbk-progress__fill`

## Markup

```html
<div class="smbk-progress smbk-progress--tone-brand smbk-progress--size-md smbk-progress--type-default smbk-progress--state-default">
  <div class="smbk-progress__header">
    <span class="smbk-progress__label">Uploading 3 of 4 files</span>
    <span class="smbk-progress__value">75%</span>
  </div>
  <div
    class="smbk-progress__track"
    role="progressbar"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-valuenow="75"
    aria-label="Uploading 3 of 4 files"
  >
    <span class="smbk-progress__fill" style="width:75%"></span>
  </div>
</div>
```

## React

```tsx
import { Progress } from './design-system/components/progress/progress.js';

<Progress tone="brand" size="md" type="default" state="default" value={75} label="Uploading 3 of 4 files" aria-label="Uploading 3 of 4 files" showValue onRetry={() => {}} />
```

## Stylesheet

```css
/* smbk-progress — Progress bar. Figma "Progress Bar" 4168:13627 and "ProgressTrack" 4168:13602.
 * The track and the fill are the same height on the same line: the empty track in the subtle track
 * color, pressed in, and the fill laid over it from the start edge, clipped by its round ends.
 * The fill's width is set inline as a percentage by the wrapper; that is data, not a design value. */

.smbk-progress {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: var(--smbk-space-stack-sm);
  width: 100%;
}

.smbk-progress__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--smbk-space-inline-md);
}

.smbk-progress__label {
  display: inline-flex;
  align-items: center;
  gap: var(--smbk-space-inline-xs);
  min-width: 0;
  color: var(--smbk-color-text-default);
  font-family: var(--smbk-typography-body-sm-font-family);
  font-size: var(--smbk-typography-body-sm-font-size);
  font-weight: var(--smbk-typography-body-sm-font-weight);
  line-height: var(--smbk-typography-body-sm-line-height);
  letter-spacing: var(--smbk-typography-body-sm-letter-spacing);
}

.smbk-progress__icon {
  flex-shrink: 0;
  color: var(--smbk-color-feedback-danger-icon);
  font-size: var(--smbk-size-icon-sm);
}

.smbk-progress__value {
  color: var(--smbk-color-text-subtle);
  font-family: var(--smbk-typography-label-md-font-family);
  font-size: var(--smbk-typography-label-md-font-size);
  font-weight: var(--smbk-typography-label-md-font-weight);
  line-height: var(--smbk-typography-label-md-line-height);
  letter-spacing: var(--smbk-typography-label-md-letter-spacing);
  text-transform: var(--smbk-typography-label-md-text-transform);
  font-variant-numeric: tabular-nums;
}

/* Try again: words underlined like a link, in a box at least big enough to press, the small press box shared in
   press-box/press-box.css, with its focus ring and its press area on a touch screen. */
.smbk-progress__retry {
  min-width: var(--smbk-size-target-min);
  min-height: var(--smbk-size-target-min);
  margin-block: calc((var(--smbk-typography-label-md-font-size) * var(--smbk-typography-label-md-line-height) - var(--smbk-size-target-min)) / 2);
  border-radius: var(--smbk-radius-control-xs);
  color: var(--smbk-color-feedback-danger-text-on-subtle);
  font-family: var(--smbk-typography-label-md-font-family);
  font-size: var(--smbk-typography-label-md-font-size);
  font-weight: var(--smbk-typography-label-md-font-weight);
  line-height: var(--smbk-typography-label-md-line-height);
  letter-spacing: var(--smbk-typography-label-md-letter-spacing);
  text-decoration: underline;
}

.smbk-progress__track {
  box-sizing: border-box;
  position: relative;
  display: flex;
  overflow: hidden;
  width: 100%;
  height: var(--smbk-space-inset-xs);
  border-radius: var(--smbk-radius-full);
  background: var(--smbk-color-surface-track-subtle);
  box-shadow: var(--smbk-elevation-inset);
}

.smbk-progress__fill {
  position: relative;
  display: block;
  height: 100%;
  border-radius: var(--smbk-radius-full);
  background: var(--smbk-color-surface-brand);
}

/* ---- tone ---- */

.smbk-progress--tone-default .smbk-progress__fill { background: var(--smbk-color-surface-inverse); }
.smbk-progress--tone-success .smbk-progress__fill { background: var(--smbk-color-feedback-success-surface); }
.smbk-progress--tone-warning .smbk-progress__fill { background: var(--smbk-color-feedback-warning-surface); }
.smbk-progress--tone-danger .smbk-progress__fill { background: var(--smbk-color-feedback-danger-surface); }

/* ---- state ---- */

/* Failed: the words and the icon in danger, the fill too, whatever the tone. */
.smbk-progress--state-failed .smbk-progress__label { color: var(--smbk-color-feedback-danger-text-on-subtle); }
.smbk-progress--state-failed .smbk-progress__fill { background: var(--smbk-color-feedback-danger-surface); }

/* Unknown: a detached part of the track, from three tenths to seven, plainly not a measured length. */
.smbk-progress--state-unknown .smbk-progress__fill {
  width: 40%;
  margin-inline-start: 30%;
}

/* ---- small: the slim track, smaller text ---- */

.smbk-progress--size-sm .smbk-progress__track {
  height: var(--smbk-space-inset-2xs);
}

.smbk-progress--size-sm .smbk-progress__label {
  font-family: var(--smbk-typography-body-xs-font-family);
  font-size: var(--smbk-typography-body-xs-font-size);
  font-weight: var(--smbk-typography-body-xs-font-weight);
  line-height: var(--smbk-typography-body-xs-line-height);
  letter-spacing: var(--smbk-typography-body-xs-letter-spacing);
}

.smbk-progress--size-sm .smbk-progress__icon {
  font-size: var(--smbk-size-icon-xs);
}

.smbk-progress--size-sm .smbk-progress__value,
.smbk-progress--size-sm .smbk-progress__retry {
  font-family: var(--smbk-typography-label-sm-font-family);
  font-size: var(--smbk-typography-label-sm-font-size);
  font-weight: var(--smbk-typography-label-sm-font-weight);
  line-height: var(--smbk-typography-label-sm-line-height);
  letter-spacing: var(--smbk-typography-label-sm-letter-spacing);
}

.smbk-progress--size-sm .smbk-progress__retry {
  margin-block: calc((var(--smbk-typography-label-sm-font-size) * var(--smbk-typography-label-sm-line-height) - var(--smbk-size-target-min)) / 2);
}

/* ---- pixel: the track cut into twenty square segments a hairline-pair apart, the fill showing
   through whole ones. A mask over the track does the cutting, so the segments are the same in the
   empty track and the fill, and the wrapper sends a value in whole segments (twentieths). The mask's
   color is never seen: only its alpha cuts. ---- */

.smbk-progress--type-pixel .smbk-progress__track {
  border-radius: 0;
  box-shadow: none;
  mask-image: linear-gradient(to right, currentcolor calc(100% - var(--smbk-space-2)), transparent 0);
  mask-size: calc((100% + var(--smbk-space-2)) / 20) 100%;
  mask-repeat: repeat-x;
}

.smbk-progress--type-pixel .smbk-progress__fill {
  border-radius: 0;
}

/* ---- motion: when the value changes, the fill grows or shrinks to it with the state purpose,
   so the change can be followed. No movement under reduced motion. ---- */

.smbk-progress__fill {
  transition: width var(--smbk-motion-state-duration) var(--smbk-motion-state-easing);
}

@media (prefers-reduced-motion: reduce) {
  .smbk-progress__fill {
    transition: none;
  }
}

/* ---- touch: the retry action's invisible press area (press-box/press-box.css) reaches over the bar under it ---- */

@media (pointer: coarse) {
  .smbk-progress__retry::after {
    z-index: 1;
  }
}

/* ---- high contrast (forced colors) ---- */
/* In Windows high-contrast mode the browser drops fills and shadows; this puts the meaning back with the
   user's own colors. Nothing here applies outside that mode. */

@media (forced-colors: active) {
  /* The empty track outlined with a hairline inside it, the done part filled with Highlight. */
  .smbk-progress__track {
    outline: var(--smbk-border-width-hairline) solid CanvasText;
    outline-offset: calc(-1 * var(--smbk-border-width-hairline));
  }

  .smbk-progress .smbk-progress__fill {
    background: Highlight;
  }
}
```

## Tokens it reads

```json
{
  "focus": {
    "ring": {
      "width": {
        "$type": "dimension",
        "$value": "{{border-width.2}}",
        "$description": "Thickness of the focus ring."
      },
      "offset": {
        "$type": "dimension",
        "$value": "{{space.2}}",
        "$description": "Gap between a control and its focus ring."
      }
    }
  },
  "color": {
    "border": {
      "focus": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Keyboard focus ring on controls."
      }
    },
    "text": {
      "default": {
        "$type": "color",
        "$value": "{{color.grey.900}}",
        "$description": "Main text."
      },
      "subtle": {
        "$type": "color",
        "$value": "{{color.grey.700}}",
        "$description": "Secondary text: descriptions, metadata."
      }
    },
    "feedback": {
      "danger": {
        "icon": {
          "$type": "color",
          "$value": "{{color.red.600}}",
          "$description": "The danger tone’s own icon (an alert sign), so the tone is never shown by colour alone."
        },
        "text-on-subtle": {
          "$type": "color",
          "$value": "{{color.red.700}}",
          "$description": "Danger messages: text on the pale fill."
        },
        "surface": {
          "$type": "color",
          "$value": "{{color.red.600}}",
          "$description": "Danger messages: strong fill."
        }
      },
      "success": {
        "surface": {
          "$type": "color",
          "$value": "{{color.green.600}}",
          "$description": "Success messages: strong fill."
        }
      },
      "warning": {
        "surface": {
          "$type": "color",
          "$value": "{{color.orange.600}}",
          "$description": "Warning messages: strong fill."
        }
      }
    },
    "surface": {
      "track-subtle": {
        "$type": "color",
        "$value": "{{color.grey.300}}",
        "$description": "The empty part of a progress track: light enough that the fill stands out by lightness, not only by hue."
      },
      "brand": {
        "$type": "color",
        "$value": "{{color.red.600}}",
        "$description": "Brand-red surface: closers, highlights."
      },
      "inverse": {
        "$type": "color",
        "$value": "{{color.grey.850}}",
        "$description": "The opposite surface: tooltips, dark chips."
      }
    }
  },
  "size": {
    "target": {
      "comfortable": {
        "$type": "dimension",
        "$value": "{{size.44}}",
        "$description": "Comfortable touch target (44)."
      },
      "min": {
        "$type": "dimension",
        "$value": "{{size.24}}",
        "$description": "Smallest tap area allowed (24, WCAG 2.2)."
      }
    },
    "icon": {
      "sm": {
        "$type": "dimension",
        "$value": "{{size.16}}",
        "$description": "Icon box, small."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{size.12}}",
        "$description": "Icon box, extra small."
      }
    }
  },
  "space": {
    "2": {
      "$type": "dimension",
      "$value": "2px",
      "$description": "Raw space step, 2 px. Only for fixed distances inside a component; spacing that follows density uses space/inset, stack or inline."
    },
    "stack": {
      "sm": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things stacked one above another, small."
      }
    },
    "inline": {
      "md": {
        "$type": "dimension",
        "$value": "{{space.8}}",
        "$description": "Gap between things side by side, medium."
      },
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Gap between things side by side, extra small."
      }
    },
    "inset": {
      "xs": {
        "$type": "dimension",
        "$value": "{{space.4}}",
        "$description": "Padding inside a component, extra small."
      },
      "2xs": {
        "$type": "dimension",
        "$value": "{{space.2}}",
        "$description": "Padding inside a component, smallest."
      }
    }
  },
  "typography": {
    "body-sm": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "14px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    },
    "label-md": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "14px",
        "fontWeight": 500,
        "lineHeight": 1.4,
        "letterSpacing": "0px"
      },
      "$description": "Label MD: labels on controls and short UI text (buttons, chips, tabs, menu headings). Geist Medium, sentence case. Code and keys use Code SM and Code MD."
    },
    "body-xs": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "12px",
        "fontWeight": 400,
        "lineHeight": 1.5,
        "letterSpacing": "0px"
      }
    },
    "label-sm": {
      "$type": "typography",
      "$value": {
        "fontFamily": "Geist, Geist Fallback, system-ui, sans-serif",
        "fontSize": "12px",
        "fontWeight": 500,
        "lineHeight": 1.4,
        "letterSpacing": "0px"
      },
      "$description": "Label SM: labels on controls and short UI text (buttons, chips, tabs, menu headings). Geist Medium, sentence case. Code and keys use Code SM and Code MD."
    }
  },
  "radius": {
    "control": {
      "xs": {
        "$type": "dimension",
        "$value": "{{radius.2}}",
        "$description": "Corners of controls, extra small."
      }
    },
    "full": {
      "$type": "dimension",
      "$value": "{{radius.9999}}",
      "$description": "Capsule: pills, chips, switches."
    }
  },
  "elevation": {
    "inset": {
      "$type": "shadow",
      "$value": {
        "light": "inset 0px 0px 1px 0px #373d434d",
        "dark": "inset 0px 0px 1px 0px #00000066"
      }
    }
  },
  "motion": {
    "state": {
      "duration": {
        "$type": "duration",
        "$value": "{{duration.200}}"
      },
      "easing": {
        "$type": "cubicBezier",
        "$value": "{{easing.cubic-in-out}}"
      }
    }
  },
  "border-width": {
    "hairline": {
      "$type": "dimension",
      "$value": "{{border-width.1}}",
      "$description": "Thin lines: dividers and almost every edge."
    }
  }
}
```

## Purpose

Shows how far an amount of work has got: an upload, a multi-step form, a quota. The fill grows along a thin track; a label above says what is happening and, optionally, the percentage. It can also say that the work failed, or that it is going on with no known end.

## Anatomy

- The block, a column: the header row, then the track.
- The header part holds the label part and the value part, at either end. When the work failed, the label leads with the icon part, a crossed circle, and the retry part, a "Try again" button underlined like a link, takes the value's place.
- The track part, a thin bar in the subtle track color, pressed into the surface, slimmer at the small size.
- The fill part, the colored bar laid over it from the start, as wide as the progress and as tall as the track. When the end is not known, it is a detached part from three tenths of the track to seven.
- In the pixel type, the track and the fill are cut into twenty square segments, and the fill shows in whole ones.

## When to use

- Any wait with a known end: uploads, downloads, steps.
- A quota or a capacity, as a static reading.
- A wait whose end is not known, with the unknown state, when the work takes long enough to need a word about it.

## When not to use

- A [skeleton](skeleton.md)'s job: holding a layout while content loads.

## Behavior

The fill's width is data, set by the wrapper from the value. When the value changes, the fill grows or shrinks to it with the state motion, so the change can be followed; under reduced motion it jumps. The pixel type rounds the value to whole segments, each a twentieth. With `showValue` the label row shows the percentage, or the value in words when `aria-valuetext` gives them, as the meter does.

Leave the value out and the bar takes the unknown state: it shows no number, and its detached fill stays still. A failed bar keeps its fill where the work stopped, in the danger color whatever its tone, and its "Try again" calls `onRetry`; without one it offers no retry.

## Accessibility

### What it does for you

- The track carries the progress-bar role with its minimum, maximum and current value; the label is its accessible name. The component puts the role, the name and `aria-valuetext` on the track.
- Assistive technology reads the value from the role, so the visible percentage is a convenience, not the source. A value above the maximum is read as the maximum.
- An unknown bar has no current value, which tells assistive technology its end is not known.
- A failed bar says so in words, its label, not by its color alone. Its retry is a real button, at least 24 pixels square to press.
- In development the wrapper warns in the console when a bar has no name.
- In high contrast mode the empty track is outlined and the done part filled with the highlight color.

### What you must do

- Give a bar with no visible label an `aria-label`, or `aria-labelledby` pointing at words elsewhere on the page.
- Give `aria-valuetext` when the value reads better in words.
- Write a failed label that names what failed, such as "Upload failed".
- Say in the label how a bar stands when its tone means something, such as "nearly full". Color must not be the only cue (WCAG 1.4.1 Use of Color).

### How to test

- With a screen reader, it is read as a progress bar by its name, with its value. An unknown bar is read with no value.
- With a failed bar, the label says what failed, and Tab reaches "Try again", which Enter or Space presses.
- With text at 200% and the page 320 pixels wide, the label row stays whole and the bar fits its width.
- In a forced-color mode, the empty track is outlined and the done part is drawn in the highlight color.

### Keyboard

| Key | What it does |
|---|---|
| Tab | On a failed bar, moves focus to "Try again". The bar itself takes no focus. |
| Enter or Space | On "Try again", presses it. |

## Usage rules

### Do

- Give every progress bar a label, even when hidden, so the role has a name.
- Use the small size in dense rows and lists.
- Write a failed label that names what failed, such as "Upload failed".

### Don't

- Use the warning tone to mean "nearly full" without saying so in the label. Color must not be the only cue (WCAG 1.4.1 Use of Color).
