Preview
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add progressindicator
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'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.
<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%.
Five-size axis · circular · indeterminate
Same sizes, no value — animated 25% arc rotating at 1.2s linear infinite.
Five-size axis · linear · determinate
Heights: xs/s=2px (Figma Small) · m=3px · l/mobile=4px (Figma Large). All at value=68%, width=200.
<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.
{
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.
{
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 gallery
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.
<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.
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional classes merged after the root defaults. |
fillColor | string | — | Fill color override (raw CSS color). Wins over the tone default. |
label | string | — | Accessible label. Required when the indicator stands alone with no surrounding caption. Falls back to `aria-label` if both are passed. |
size | enum | m | Visual size. Default `'m'`. |
slotClassName | ProgressIndicatorSlotClassNames | — | Per-slot className overrides. |
tone | enum | primary | Color tone. Default `'secondary'` — matches the Figma Small/Large/Linear surfaces. |
trackColor | string | — | Track color override (raw CSS color). Wins over the tone default. Useful when the surrounding context demands a non-token tint. |
type | enum | circular | Orientation. Default `'circular'`. |
value | number | — | Determinate value (0–100). Omit for indeterminate (animated) progress. Values outside the 0–100 range are clamped. |
valueText | string | — | Custom `aria-valuetext` for determinate progress. Defaults to `${Math.round(value)}%` if omitted. |
width | string | number | — | Width 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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
rootOutermost 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`.
trackBackground ring (circular) or full-width band (linear). Tint sourced from the tone's 100/200 token; raw color override via `trackColor`.
fillForeground 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
<ProgressIndicator size="xs" tone="primary" label="Saving" />
<ProgressIndicator value={68} label="Uploading model" /><ProgressIndicator type="linear" size="l" label="Importing" />
<ProgressIndicator size="l" tone="success" label="Verifying signature" />
✗ Don't
<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.
<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.
<><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.
<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.