Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
Data DisplayUpdatedFreeWCAG 2.2 AA

Progress Indicator

Linear / circular progress.

Preview

Live preview
@oshon-ai/components
Linear — determinate, brand-default tone
Linear — every semantic tone
Linear — indeterminate (sliding shimmer)
Circular — determinate, all five sizes
xs
s
m
l
mobile
Circular — indeterminate (rotating arc)

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add progressindicator

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { ProgressIndicator } from '@oshon-ai/components';

export default function Example() {
  return <ProgressIndicator />;
}

Figma reference

Figma reference frame

Five published variants from OSH › Progress Indicators (3520:20262). Determinate at 50% so the fill arc is visible.

Circular · Cell · primary
Circular · Small · secondary
Circular · Large · secondary
Linear · Small · secondary
Linear · Large · secondary
tsx
<Stage>
      <Card
        title="Figma reference frame"
        caption="Five published variants from OSH › Progress Indicators (3520:20262). Determinate at 50% so the fill arc is visible."
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            gap: 32,
            padding: '32px 16px',
          }}
        >
          <Sample label="Circular · Cell · primary">
            <ProgressIndicator
              size="xs"
              tone="primary"
              value={50}
              label="Loading"
            />
          </Sample>
          <Sample label="Circular · Small · secondary">
            <ProgressIndicator size="m" value={50} label="Loading" />
          </Sample>
          <Sample label="Circular · Large · secondary">
            <ProgressIndicator size="l" value={50} label="Loading" />
          </Sample>
          <Sample label="Linear · Small · secondary">
            <ProgressIndicator
              type="linear"
              size="s"
              value={50}
              label="Loading"
            />
          </Sample>
          <Sample label="Linear · Large · secondary">
            <ProgressIndicator
              type="linear"
              size="l"
              value={50}
              label="Loading"
            />
          </Sample>
        </div>
      </Card>
    </Stage>

Size axis

Five-size axis · circular · determinate

Diameters: xs=16 · s=32 · m=48 · l=96 · mobile=48. All at value=68%.

xs
s
m
l
mobile

Five-size axis · circular · indeterminate

Same sizes, no value — animated 25% arc rotating at 1.2s linear infinite.

xs
s
m
l
mobile

Five-size axis · linear · determinate

Heights: xs/s=2px (Figma Small) · m=3px · l/mobile=4px (Figma Large). All at value=68%, width=200.

xs
s
m
l
mobile
tsx
<Stage>
      <Card
        title="Five-size axis · circular · determinate"
        caption="Diameters: xs=16 · s=32 · m=48 · l=96 · mobile=48. All at value=68%."
      >
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-around',
            flexWrap: 'wrap',
            gap: 16,
          }}
        >
          <Sample label="xs">
            <ProgressIndicator size="xs" tone="primary" value={68} label="Loading" />
          </Sample>
          <Sample label="s">
            <ProgressIndicator size="s" value={68} label="Loading" />
          </Sample>
          <Sample label="m">
            <ProgressIndicator size="m" value={68} label="Loading" />
          </Sample>
          <Sample label="l">
            <ProgressIndicator size="l" value={68} label="Loading" />
          </Sample>
          <Sample label="mobile">
            <ProgressIndicator size="mobile" value={68} label="Loading" />
          </Sample>
        </div>
      </Card>

      <Card
        title="Five-size axis · circular · indeterminate"
        caption="Same sizes, no value — animated 25% arc rotating at 1.2s linear infinite."
      >
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-around',
            flexWrap: 'wrap',
            gap: 16,
          }}
        >
          <Sample label="xs">
            <ProgressIndicator size="xs" tone="primary" label="Loading" />
          </Sample>
          <Sample label="s">
            <ProgressIndicator size="s" label="Loading" />
          </Sample>
          <Sample label="m">
            <ProgressIndicator size="m" label="Loading" />
          </Sample>
          <Sample label="l">
            <ProgressIndicator size="l" label="Loading" />
          </Sample>
          <Sample label="mobile">
            <ProgressIndicator size="mobile" label="Loading" />
          </Sample>
        </div>
      </Card>

      <Card
        title="Five-size axis · linear · determinate"
        caption="Heights: xs/s=2px (Figma Small) · m=3px · l/mobile=4px (Figma Large). All at value=68%, width=200."
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            gap: 24,
            alignItems: 'center',
            padding: '12px 0',
          }}
        >
          {(['xs', 's', 'm', 'l', 'mobile'] as const).map((sz) => (
            <div
              key={sz}
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 16,
                width: 320,
              }}
            >
              <span
                style={{
                  width: 56,
                  fontSize: 11,
                  fontWeight: 500,
                  textTransform: 'uppercase',
                  letterSpacing: '0.05em',
                  color: 'var(--oshon-color-neutral-600, #687576)',
                }}
              >
                {sz}
              </span>
              <ProgressIndicator
                type="linear"
                size={sz}
                value={68}
                label="Loading"
              />
            </div>
          ))}
        </div>
      </Card>
    </Stage>

Tone axis

Six tones

Each tone maps to a token pair (track 100/200 + fill 500/600). Replace the tokens via createTheme to white-label everything.

primary
secondary
success
warning
danger
neutral
tsx
{
    const tones: ProgressIndicatorTone[] = [
      'primary',
      'secondary',
      'success',
      'warning',
      'danger',
      'neutral',
    ];
    return (
      <Stage>
        <Card
          title="Six tones"
          caption="Each tone maps to a token pair (track 100/200 + fill 500/600). Replace the tokens via createTheme to white-label everything."
        >
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
              gap: 24,
            }}
          >
            {tones.map((tone) => (
              <Sample key={tone} label={tone}>
                <ProgressIndicator
                  size="m"
                  tone={tone}
                  value={42}
                  label={`${tone} progress`}
                />
              </Sample>
            ))}
          </div>
        </Card>
      </Stage>
    );
  }

Live progress

Live determinate progress

Both the circular and linear surfaces animate the same value. Determinate fills transition smoothly (200ms linear) so polling-driven updates don't jitter.

0%
Linear · l · 0%
tsx
{
    const [value, setValue] = useState(0);

    useEffect(() => {
      const id = setInterval(() => {
        setValue((v) => (v >= 100 ? 0 : Math.min(100, v + 4)));
      }, 200);
      return () => clearInterval(id);
    }, []);

    return (
      <Stage>
        <Card
          title="Live determinate progress"
          caption="Both the circular and linear surfaces animate the same value. Determinate fills transition smoothly (200ms linear) so polling-driven updates don't jitter."
        >
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'space-around',
              flexWrap: 'wrap',
              gap: 32,
            }}
          >
            <Sample label={`${Math.round(value)}%`}>
              <ProgressIndicator
                size="l"
                value={value}
                label="Loading"
              />
            </Sample>
            <div
              style={{
                display: 'flex',
                flexDirection: 'column',
                alignItems: 'center',
                gap: 12,
                minWidth: 240,
              }}
            >
              <ProgressIndicator
                type="linear"
                size="l"
                value={value}
                label="Loading"
              />
              <span
                style={{
                  fontSize: 11,
                  fontWeight: 500,
                  color: 'var(--oshon-color-neutral-600, #687576)',
                  textTransform: 'uppercase',
                  letterSpacing: '0.05em',
                }}
              >
                Linear · l · {Math.round(value)}%
              </span>
            </div>
          </div>
        </Card>
      </Stage>
    );
  }

Indeterminate progress

Omit `value` to switch to indeterminate mode. Circular spins a 25% arc; linear slides a 40% bar. Both run on the compositor (transform-only) and respect prefers-reduced-motion.

Cell · primary
Small · secondary
Large · secondary
tsx
<Stage>
      <Card
        title="Indeterminate progress"
        caption="Omit `value` to switch to indeterminate mode. Circular spins a 25% arc; linear slides a 40% bar. Both run on the compositor (transform-only) and respect prefers-reduced-motion."
      >
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-around',
            flexWrap: 'wrap',
            gap: 32,
          }}
        >
          <Sample label="Cell · primary">
            <ProgressIndicator size="xs" tone="primary" label="Loading" />
          </Sample>
          <Sample label="Small · secondary">
            <ProgressIndicator size="m" label="Loading" />
          </Sample>
          <Sample label="Large · secondary">
            <ProgressIndicator size="l" label="Loading" />
          </Sample>
        </div>
        <div
          style={{
            marginTop: 32,
            display: 'flex',
            justifyContent: 'center',
          }}
        >
          <ProgressIndicator
            type="linear"
            size="l"
            width={320}
            label="Importing data"
          />
        </div>
      </Card>
    </Stage>

In context

Inline cell-sized spinner

Use size=xs and tone=primary alongside a label or status text. Matches the Figma Cell variant (3520:20261, 16×16).

  • Compiling tokens.css
  • Bundling components
  • Generating manifests
  • Writing changeset

Footer linear bar

A linear indeterminate bar pinned to the bottom of a card — the typical AIChat / FileUpload progress pattern.

Importing source.csv12,438 rows processed · estimated time remaining unknown
tsx
<Stage>
      <Card
        title="Inline cell-sized spinner"
        caption="Use size=xs and tone=primary alongside a label or status text. Matches the Figma Cell variant (3520:20261, 16×16)."
      >
        <ul
          style={{
            listStyle: 'none',
            margin: 0,
            padding: 0,
            display: 'flex',
            flexDirection: 'column',
            gap: 16,
          }}
        >
          {[
            { name: 'Compiling tokens.css', status: 'pending' },
            { name: 'Bundling components', status: 'done' },
            { name: 'Generating manifests', status: 'pending' },
            { name: 'Writing changeset', status: 'idle' },
          ].map((row) => (
            <li
              key={row.name}
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: 12,
                padding: '12px 16px',
                background: 'var(--oshon-color-neutral-100, #f5f6f6)',
                borderRadius: 8,
                fontSize: 14,
              }}
            >
              <span
                style={{
                  width: 16,
                  height: 16,
                  display: 'inline-flex',
                  alignItems: 'center',
                  justifyContent: 'center',
                }}
              >
                {row.status === 'pending' ? (
                  <ProgressIndicator
                    size="xs"
                    tone="primary"
                    label={`${row.name} (in progress)`}
                  />
                ) : row.status === 'done' ? (
                  <span
                    aria-label={`${row.name} (done)`}
                    style={{
                      width: 12,
                      height: 12,
                      borderRadius: '50%',
                      background:
                        'var(--oshon-color-success-500, #5dc265)',
                    }}
                  />
                ) : (
                  <span
                    aria-label={`${row.name} (idle)`}
                    style={{
                      width: 8,
                      height: 8,
                      borderRadius: '50%',
                      background:
                        'var(--oshon-color-neutral-400, #c2c8c8)',
                    }}
                  />
                )}
              </span>
              <span style={{ flex: 1 }}>{row.name}</span>
            </li>
          ))}
        </ul>
      </Card>

      <Card
        title="Footer linear bar"
        caption="A linear indeterminate bar pinned to the bottom of a card — the typical AIChat / FileUpload progress pattern."
      >
        <div
          style={{
            position: 'relative',
            border: '1px solid var(--oshon-color-neutral-300, #dfe2e2)',
            borderRadius: 8,
            overflow: 'hidden',
          }}
        >
          <div
            style={{
              padding: 24,
              minHeight: 120,
              fontSize: 14,
              color: 'var(--oshon-color-neutral-700, #4a5455)',
            }}
          >
            <strong style={{ display: 'block', marginBottom: 8 }}>
              Importing source.csv
            </strong>
            12,438 rows processed · estimated time remaining unknown
          </div>
          <ProgressIndicator
            type="linear"
            size="l"
            width="100%"
            label="Importing source.csv"
            slotClassName={{ root: 'rounded-none' }}
          />
        </div>
      </Card>
    </Stage>

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
classNamestringAdditional classes merged after the root defaults.
fillColorstringFill color override (raw CSS color). Wins over the tone default.
labelstringAccessible label. Required when the indicator stands alone with no surrounding caption. Falls back to `aria-label` if both are passed.
sizeenummVisual size. Default `'m'`.
slotClassNameProgressIndicatorSlotClassNamesPer-slot className overrides.
toneenumprimaryColor tone. Default `'secondary'` — matches the Figma Small/Large/Linear surfaces.
trackColorstringTrack color override (raw CSS color). Wins over the tone default. Useful when the surrounding context demands a non-token tint.
typeenumcircularOrientation. Default `'circular'`.
valuenumberDeterminate value (0–100). Omit for indeterminate (animated) progress. Values outside the 0–100 range are clamped.
valueTextstringCustom `aria-valuetext` for determinate progress. Defaults to `${Math.round(value)}%` if omitted.
widthstring | numberWidth override for the linear variant (default 200 — Figma spec). Number → `${n}px`; string passes through (e.g. `'100%'`).

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<ProgressIndicator
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

root

Outermost wrapper. Carries `data-oshon-component="progress-indicator"`, `data-oshon-type`, `data-oshon-size`, `data-oshon-tone`, and `data-oshon-determinate` for QA hooks. Override classes via `slotClassName.root`.

track

Background ring (circular) or full-width band (linear). Tint sourced from the tone's 100/200 token; raw color override via `trackColor`.

fill

Foreground arc (circular) or progress bar (linear). Tint sourced from the tone's 500/600 token; raw color override via `fillColor`. Carries the indeterminate animation transform when `value` is omitted.

Keyboard

No interactive surface — ProgressIndicator is purely status. Renders `role="progressbar"` with `aria-valuemin=0`, `aria-valuemax=100`. Determinate sets `aria-valuenow={Math.round(value)}` and `aria-valuetext` (caller-overridable via `valueText`). Indeterminate omits `aria-valuenow` per ARIA spec. Pass `label` (or `aria-label`) when the indicator stands alone with no surrounding caption — screen readers announce it as "progress bar, [label], [valuetext]". The animation is decorative and runs on the compositor thread; no JS timers.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-28

Do / Don't

✓ Do

Inline cell-sized loading hint next to a label
<ProgressIndicator size="xs" tone="primary" label="Saving" />
Determinate upload progress at 68%
<ProgressIndicator value={68} label="Uploading model" />
Linear bar at the bottom of a card
<ProgressIndicator type="linear" size="l" label="Importing" />
Indeterminate ring with a caller-supplied tone
<ProgressIndicator size="l" tone="success" label="Verifying signature" />

✗ Don't

Showing the indicator without an accessible label
<ProgressIndicator value={42} />

A standalone progress bar with no surrounding caption needs a `label` (or `aria-label`) so screen readers can announce *what* is loading. Pass a short verb phrase ("Saving", "Importing") rather than a noun.

Toggling between determinate and indeterminate mid-flight
<ProgressIndicator value={uncertain ? undefined : pct} />

The DOM swap (track + fill arc → spinning arc) re-mounts the SVG and produces a visible jump. Pick one mode for the lifetime of the operation; if you don't know the total ahead of time, stay indeterminate until completion.

Stacking two ProgressIndicators on top of each other
<><ProgressIndicator value={a} /><ProgressIndicator value={b} /></>

Two animated indicators in the same visual neighborhood compete for attention and tank perceived performance. Use a single multi-step indicator (Stepper / segmented bar) or sequence the operations.

Hardcoding a Figma color string instead of using `tone`
<ProgressIndicator fillColor="#ffbf5f" />

Bypassing the tone tokens defeats white-label theming (principle #6). Use `tone="secondary"` for the Figma yellow; use `fillColor` only when you genuinely need a non-token tint.

Design rationale

Progress is one of the few pure-status surfaces where the design system can dictate behaviour without a hosting context — ProgressIndicator owns its own a11y contract, animation contract, and reduced-motion fallback. The Figma surface ships three circular sizes (Cell/Small/Large) and two linear sizes (Small/Large); we extend each to the canonical five-size axis with a single interpolated step (s circular = 32, m linear = 3) so consumers don't have to reach for size aliases. Tone defaults to `secondary` (Tradewinds yellow — the Figma default for Small, Large, and both linear variants); the `xs` Cell variant in Figma uses primary blue, surfaced via `tone='primary'`. Determinate vs indeterminate is driven by `value` presence rather than a separate prop because the two modes are mutually exclusive — passing both would invite drift. Determinate fills transition over 200ms linear so polling-based progress (every second or so) animates smoothly without lag. Indeterminate animations run via CSS keyframes injected once per document so the component stays free of inline `<style>` per render.