Forms and inputsProgress and meter
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callProgress and meter
A run's progress as a ring, steps and a bar, beside a credits meter that warns as it fills. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add progressReact
npx designme add progress --reactAstro
npx designme add progress --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/progress.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/progress.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
One run shown three ways, plus a meter. A ring gives the whole run as a percentage, a row of segments gives the steps (step 3 of 5), and a bar gives how far the step in hand is, with its count in words. The run can be indeterminate while it works out how much there is, running, paused, failed with Retry, or done. Under it, a credits meter fills and warns in words at 75% (Running low) and more strongly at 90%.
- For an agent run whose length you only know once it has started: show it indeterminate first, then switch to the real numbers.
- When a run has named steps and each step has its own count, like pages fetched or files indexed.
- Beside a run that spends a budget, so people see the budget fill and get warned before it runs out.
- Five states: indeterminate (the ring turns a short arc and the bar sweeps), running (the accent), paused (grey), error (red, with the error over the bar and a Retry button) and complete (green, with a check in the ring).
- A line under the title in plain words, such as "Step 3 of 5, indexing files" or "Paused at step 3 of 5"; pass
valueTextfor your own. - A status tag (Starting, Running, Paused, Failed, Done) and one button beside it: Pause while running, Resume while paused, Run again once complete when
onRestartis set. - The step's count over its bar, like "1,240 of 2,000", from each step's
unitandtotal. - An optional
meterwith two thresholds, drawn as notches through the bar: fromwarnAt(75%) its bar turns orange and a Running low tag appears, fromdangerAt(90%) it turns red and saysdangerLabel. - The line over the bar and the button keep their place in every state, so the component never changes height as the run goes on.
- The ring, the segments and the bar each have role="progressbar" with an aria-valuetext in words ("62% of the run", "Step 3 of 5", "1,240 of 2,000 files"); while indeterminate they have no value.
- The meter has role="meter", with its value and its warning in aria-valuetext ("9,280 of 10,000 credits, almost out").
- A polite live region announces only changes of state (Paused, Step 4 failed, Done), never every percent.
- The warnings are words in a tag, not only a colour.
- With reduced motion the ring stops turning and the indeterminate bar shows as a still, faint fill.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { Progress } from '@/components/designme/progress/Progress';React example
The demo above as a React component, 60 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: a run starts, its steps fill, the fourth step
// times out and is retried, while the credits meter crosses 75% and then 90%.
import { useEffect, useRef, useState } from 'react';
import { Progress, type ProgressState, type ProgressStep } from './Progress';
import { useStory, useWait } from '../lib/useScript';
const STEPS: ProgressStep[] = [
{ label: 'Reading the sitemap', unit: 'Pages', total: 48 }, { label: 'Fetching pages', unit: 'Pages', total: 48 },
{ label: 'Indexing files', unit: 'Files', total: 2000 }, { label: 'Writing embeddings', unit: 'Chunks', total: 6400 }, { label: 'Checking links', unit: 'Links', total: 310 }
];
export default function ProgressDemo() {
const ref = useRef<HTMLDivElement>(null);
const [state, setState] = useState<ProgressState>('indeterminate');
const [step, setStep] = useState(0);
const [sub, setSub] = useState(0);
const [credits, setCredits] = useState(7000);
const live = useRef(state); live.current = state;
const run = useRef(0);
// the agent's run goes on whatever the visitor does; only the person in the preview (the Retry press) stops when they take over
const wait = useWait();
const go = async () => {
const id = ++run.current;
setState('indeterminate'); setStep(0); setSub(0); setCredits(7000);
await wait(1800); if (id !== run.current) return;
setState('running');
let failed = false;
for (let s = 0; s < STEPS.length; s++) {
setStep(s); setSub(0);
for (let k = 1; k <= 12; k++) {
await wait(150);
while (live.current === 'paused') await wait(100);
if (id !== run.current) return;
if (s === 3 && k === 6 && !failed) {
failed = true; setState('error');
while (live.current === 'error') await wait(100);
await wait(400);
}
setSub(k / 12); setCredits(c => c + 38);
}
}
setState('complete');
};
useEffect(() => { void go(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
// the person in the preview retries the failed step; an agent at work keeps working under the pointer
useStory(ref, async ({ wait, press }) => {
while (live.current !== 'error') await wait(100);
await wait(1500); press('.dm-prg-ll .dm-pill');
}, { hold: false });
return (
<div ref={ref} style={{ width: '100%' }}>
<Progress title="Indexing the docs" state={state} steps={STEPS} step={step} stepProgress={sub} error="The embeddings service timed out"
valueText={state === 'complete' ? 'Indexed 2,000 files' : undefined}
onPause={() => setState('paused')} onResume={() => setState('running')} onRetry={() => setState('running')} onRestart={() => { void go(); }}
meter={{ label: 'Credits this month', value: credits, max: 10000, unit: 'credits', note: 'Resets Dec 1', dangerLabel: 'Almost out' }} />
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its progress.js to show your own data. Attributes like class go on its root element.
---
import Progress from '../components/designme/progress/Progress.astro';
---
<Progress />
<Progress data-style="soft" class="my-class" />Props
Everything Progress takes.
| Prop | Type | What it does |
|---|---|---|
titlerequired | string | Like "Indexing the docs" |
staterequired | ProgressState | indeterminate: the ring turns and the bar sweeps; running: the accent; paused: grey; error: red with Retry; complete: green |
stepsrequired | ProgressStep[] | |
steprequired | number | The step in hand, from 0 |
stepProgressrequired | number | How far the step in hand is, 0 to 1 |
valueText | string | The line under the title. Defaults to "Step 3 of 5, indexing files" and the like |
error | string | What failed, shown over the bar with a Retry button while `state` is "error" |
onPause | () => void | |
onResume | () => void | |
onRetry | () => void | |
onRestart | () => void | Adds a Run again button once complete |
meter | ProgressMeter | A meter under the run, with thresholds that warn in words |
className | string |
Code
The full source, as the CLI installs it.
Progress.tsxReact · 122 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cx } from '../lib/cx';
import { Icon } from '../lib/icons';
import { Swap, Tip } from '../lib/motion';
import '../designme.css';
import './progress.css';
export type ProgressState = 'indeterminate' | 'running' | 'paused' | 'error' | 'complete';
export interface ProgressStep {
/** Like "Indexing files" */
label: string;
/** What the step counts, like "Files" */
unit: string;
/** How many there are to count, like 2000 */
total: number;
}
export interface ProgressMeter {
/** Like "Credits this month" */
label: string;
value: number;
max: number;
/** The unit in the meter's spoken value, like "credits" */
unit?: string;
/** Share of `max` where it says "Running low" (0.75) */
warnAt?: number;
/** Share of `max` where it warns more strongly (0.9) */
dangerAt?: number;
/** The words for the stronger warning ("Almost out") */
dangerLabel?: string;
/** Quiet text at the right under the meter, like "Resets Dec 1" */
note?: string;
}
export interface ProgressProps {
/** Like "Indexing the docs" */
title: string;
/** indeterminate: the ring turns and the bar sweeps; running: the accent; paused: grey; error: red with Retry; complete: green */
state: ProgressState;
steps: ProgressStep[];
/** The step in hand, from 0 */
step: number;
/** How far the step in hand is, 0 to 1 */
stepProgress: number;
/** The line under the title. Defaults to "Step 3 of 5, indexing files" and the like */
valueText?: string;
/** What failed, shown over the bar with a Retry button while `state` is "error" */
error?: string;
onPause?: () => void;
onResume?: () => void;
onRetry?: () => void;
/** Adds a Run again button once complete */
onRestart?: () => void;
/** A meter under the run, with thresholds that warn in words */
meter?: ProgressMeter;
className?: string;
}
const CODE: Record<ProgressState, string> = { indeterminate: 'ind', running: 'run', paused: 'paused', error: 'err', complete: 'done' };
const TAG: Record<ProgressState, [string, string]> = { indeterminate: ['Starting', ''], running: ['Running', 'dm-run'], paused: ['Paused', ''], error: ['Failed', 'dm-bad'], complete: ['Done', 'dm-ok'] };
const fmt = (n: number) => Math.round(n).toLocaleString('en-US');
/** A run's progress three ways (a ring for the whole run, segments for its steps, a bar for the step in hand), with an optional meter that warns as it fills. */
export function Progress({ title, state, steps, step, stepProgress, valueText, error = 'The step failed', onPause, onResume, onRetry, onRestart, meter, className }: ProgressProps) {
const n = steps.length, at = Math.max(0, Math.min(step, n - 1)), cur = steps[at];
const done = state === 'complete', ind = state === 'indeterminate';
const sub = done ? 1 : Math.max(0, Math.min(1, stepProgress));
const all = done ? 1 : (at + sub) / n, pct = Math.round(all * 100);
const words = valueText ?? (ind ? 'Preparing the run' : state === 'paused' ? `Paused at step ${at + 1} of ${n}` : state === 'error' ? `Failed at step ${at + 1} of ${n}` : done ? `All ${n} steps done` : `Step ${at + 1} of ${n}, ${cur.label.toLowerCase()}`);
// only a change of state is announced, never every percent
const [said, setSaid] = useState('');
const was = useRef(state);
useEffect(() => {
if (was.current === state) return; was.current = state;
setSaid({ indeterminate: 'Starting', running: 'Running', paused: 'Paused', error: `Step ${at + 1} failed: ${error}`, complete: 'Done. ' + words }[state]);
}, [state]); // eslint-disable-line react-hooks/exhaustive-deps
const btn = state === 'paused' ? { icon: 'play' as const, label: 'Resume', on: onResume } : done ? { icon: 'refresh' as const, label: 'Run again', on: onRestart } : { icon: 'pause' as const, label: 'Pause', on: onPause };
const m = meter ? meter.value / meter.max : 0, warnAt = meter?.warnAt ?? .75, dangerAt = meter?.dangerAt ?? .9;
const band = !meter ? 0 : m >= dangerAt ? 2 : m >= warnAt ? 1 : 0;
return (
<div data-dm="" data-s={CODE[state]} className={cx('dm-prg dm-panel', className)}>
<div className="dm-prg-h">
<span className="dm-prg-ring" role="progressbar" aria-label="Run progress" aria-valuemin={ind ? undefined : 0} aria-valuemax={ind ? undefined : 100} aria-valuenow={ind ? undefined : pct} aria-valuetext={ind ? 'Preparing the run' : pct + '% of the run'}>
<svg viewBox="0 0 36 36" aria-hidden="true"><circle className="dm-tr" cx="18" cy="18" r="15" /><circle className="dm-fl" cx="18" cy="18" r="15" pathLength={100} style={ind ? undefined : { strokeDasharray: `${all * 100} 100` }} /></svg>
<span className="dm-prg-pc dm-num">{done ? <Icon name="check" size="xs" className="dm-enter" /> : ind ? '' : pct + '%'}</span>
</span>
<div className="dm-prg-t"><b>{title}</b><span className="dm-muted dm-prg-vt">{words}</span></div>
<span className={cx('dm-tag dm-prg-st', TAG[state][1])}><Swap>{TAG[state][0]}</Swap></span>
{btn.on && <Tip label={btn.label}><button type="button" className="dm-ib dm-prg-btn" aria-label={btn.label} onClick={btn.on} disabled={state === 'error' || ind} style={state === 'error' ? { visibility: 'hidden' } : undefined}><Icon name={btn.icon} /></button></Tip>}
</div>
<div className="dm-prg-seg" role="progressbar" aria-label="Steps" aria-valuemin={0} aria-valuemax={n} aria-valuenow={done ? n : at} aria-valuetext={done ? `All ${n} steps done` : ind ? 'Not started' : `Step ${at + 1} of ${n}`}>
{steps.map((_, j) => <i key={j}><b style={{ ['--dm-f' as string]: done || j < at ? 1 : j === at && !ind ? sub : 0 }} /></i>)}
</div>
<div className="dm-prg-ll">
{state === 'error'
? <><span className="dm-e dm-enter"><Icon name="alert" size="xs" /><span>{error}</span></span>{onRetry && <button type="button" className="dm-pill dm-line dm-sm" onClick={onRetry}><Icon name="refresh" size="xs" />Retry</button>}</>
: ind ? <><span>Getting ready</span><span className="dm-n">Counting</span></> : <><span>{cur.unit}</span><span className="dm-n dm-num">{fmt(sub * cur.total)} of {fmt(cur.total)}</span></>}
</div>
<div className={cx('dm-prg-lin', ind && 'dm-ind')} role="progressbar" aria-label="This step" aria-valuemin={0} aria-valuemax={100} aria-valuenow={ind ? undefined : Math.round(sub * 100)} aria-valuetext={ind ? 'Working out how much there is' : `${fmt(sub * cur.total)} of ${fmt(cur.total)} ${cur.unit.toLowerCase()}`}>
<i style={ind ? undefined : { width: sub * 100 + '%' }} />
</div>
{meter && (
<div className="dm-prg-meter">
<div className="dm-prg-mh"><span>{meter.label}</span><span className="dm-num dm-muted"><b className="dm-prg-mv">{fmt(meter.value)}</b> of {fmt(meter.max)}</span></div>
<div className={cx('dm-prg-mb', band === 1 && 'dm-warn', band === 2 && 'dm-bad')} role="meter" aria-label={meter.label} aria-valuemin={0} aria-valuemax={meter.max} aria-valuenow={Math.round(meter.value)}
aria-valuetext={`${fmt(meter.value)} of ${fmt(meter.max)} ${meter.unit ?? ''}`.trim() + ['', ', running low', ', ' + (meter.dangerLabel ?? 'almost out').toLowerCase()][band]}>
<i className="dm-fl" style={{ width: Math.min(100, m * 100) + '%' }} /><i className="dm-th" style={{ left: warnAt * 100 + '%' }} /><i className="dm-th" style={{ left: dangerAt * 100 + '%' }} />
</div>
<div className="dm-prg-ml">
<span key={band} className={band ? 'dm-enter' : undefined}>{band === 2 ? <span className="dm-tag dm-bad"><Icon name="alert" size="xs" />{meter.dangerLabel ?? 'Almost out'}</span> : band === 1 ? <span className="dm-tag dm-warn"><Icon name="warn" size="xs" />Running low</span> : <span className="dm-muted">On track</span>}</span>
{meter.note && <span className="dm-muted">{meter.note}</span>}
</div>
</div>
)}
<p className="dm-prg-sr" aria-live="polite">{said}</p>
</div>
);
}
progress.cssReact · 49 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Progress and meter ---------- */
/* one run, three ways to read it: the ring is the whole run, the segments its steps (n of m), the bar the step in hand.
Its colour says how it is going: the accent while it runs, grey while paused, red when a step fails, green once done */
.dm-prg{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-prg-c:var(--dm-accent-mark,var(--dm-accent))}
.dm-prg[data-s="paused"]{--dm-prg-c:var(--dm-faint)}
.dm-prg[data-s="err"]{--dm-prg-c:var(--dm-bad)}
.dm-prg[data-s="done"]{--dm-prg-c:var(--dm-ok-mark)}
.dm-prg-h{display:flex;align-items:center;gap:var(--dm-s-3)}
.dm-prg-ring{position:relative;width:44px;height:44px;flex:none;display:grid;place-items:center;color:var(--dm-ok-mark)}
.dm-prg-ring>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
.dm-prg-ring circle{fill:none;stroke-width:3}
.dm-prg-ring .dm-tr{stroke:var(--dm-chip)}
.dm-prg-ring .dm-fl{stroke:var(--dm-prg-c);stroke-dasharray:0 100;stroke-linecap:round;transition:stroke-dasharray .6s var(--dm-ease),stroke .3s}
/* not knowing how long it will take, the ring turns a short arc and the bar sweeps */
.dm-prg[data-s="ind"] .dm-prg-ring>svg{animation:dm-rot 1.1s linear infinite}
.dm-prg[data-s="ind"] .dm-prg-ring .dm-fl{stroke-dasharray:25 100;transition:none}
.dm-prg-pc{font-size:13px;font-weight:500;color:var(--dm-fg);line-height:1;letter-spacing:-.02em}
.dm-prg-t{flex:1;min-width:0;display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.dm-prg-t b{font-weight:500}
.dm-prg-vt{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-prg-st{flex:none}
.dm-prg-st.dm-run{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.dm-prg-seg{display:flex;gap:var(--dm-s-1)}
.dm-prg-seg i,.dm-prg-lin{position:relative;flex:none;height:6px;border-radius:calc(3px * var(--dm-rk));background:var(--dm-chip);overflow:hidden}
.dm-prg-seg i{flex:1}
.dm-prg-seg b{position:absolute;inset:0;border-radius:inherit;background:var(--dm-prg-c);transform-origin:left;transform:scaleX(var(--dm-f,0));transition:transform .45s var(--dm-ease),background .3s}
.dm-prg-ll{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);min-height:28px;margin-bottom:calc(var(--dm-s-1) * -1);font-size:12.5px;color:var(--dm-fg2)}
.dm-prg-ll .dm-n{color:var(--dm-muted)}
.dm-prg-ll .dm-e{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dm-bad)}
.dm-prg-ll .dm-e span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-prg-lin i{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:inherit;background:var(--dm-prg-c);transition:width .45s var(--dm-ease),background .3s}
.dm-prg-lin.dm-ind i{width:32%;animation:dm-prgsweep 1.3s var(--dm-ease-io) infinite;transition:none}
@keyframes dm-prgsweep{from{transform:translateX(-100%)}to{transform:translateX(320%)}}
.dm-prg-meter{display:flex;flex-direction:column;gap:var(--dm-s-2);margin-top:var(--dm-s-1);padding-top:var(--dm-s-4);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-prg-mh{display:flex;align-items:baseline;justify-content:space-between;gap:var(--dm-s-2);font-size:13px}
.dm-prg-mh b{font-weight:500;color:var(--dm-fg)}
.dm-prg-mh .dm-muted{font-size:12.5px}
.dm-prg-mb{position:relative;height:8px;border-radius:calc(4px * var(--dm-rk));background:var(--dm-chip)}
.dm-prg-mb .dm-fl{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:inherit;background:var(--dm-accent-mark,var(--dm-accent));transition:width .6s var(--dm-ease),background .3s}
.dm-prg-mb.dm-warn .dm-fl{background:var(--dm-warn-mark)}
.dm-prg-mb.dm-bad .dm-fl{background:var(--dm-bad)}
/* where the meter starts to warn: a notch through the bar at 75% and 90% */
.dm-prg-mb .dm-th{position:absolute;top:-3px;bottom:-3px;width:2px;margin-left:-1px;border-radius:1px;background:color-mix(in srgb,var(--dm-fg) 35%,transparent)}
.dm-prg-ml{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--dm-s-1) var(--dm-s-2);min-height:20px;font-size:12px}
.dm-prg-ml .dm-tag .dm-i{margin-left:-1px}
.dm-prg-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media (prefers-reduced-motion:reduce){.dm-prg[data-s="ind"] .dm-prg-ring>svg{animation:none}.dm-prg-lin.dm-ind i{animation:none;width:100%;opacity:.35}}
Progress.astroAstro · 40 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Progress and meter: A run's progress as a ring, steps and a bar, beside a credits meter that warns as it fills.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change progress.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './progress.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="progress" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="prg panel" data-s="ind">
<div class="prg-h">
<span class="prg-ring" role="progressbar" aria-label="Run progress"><svg viewBox="0 0 36 36" aria-hidden="true"><circle class="tr" cx="18" cy="18" r="15"/><circle class="fl" cx="18" cy="18" r="15" pathLength="100"/></svg><span class="prg-pc num"></span></span>
<div class="prg-t"><b>Indexing the docs</b><span class="muted prg-vt">Preparing the run</span></div>
<span class="tag prg-st">Starting</span>
<button class="ib prg-btn" aria-label="Pause" data-tip="Pause"><svg class="i s"><use href="#i-pause"/></svg></button>
</div>
<div class="prg-seg" role="progressbar" aria-label="Steps" aria-valuemin="0" aria-valuemax="5"><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i></div>
<div class="prg-ll"></div>
<div class="prg-lin ind" role="progressbar" aria-label="This step" aria-valuemin="0" aria-valuemax="100"><i></i></div>
<div class="prg-meter">
<div class="prg-mh"><span>Credits this month</span><span class="num muted"><b class="prg-mv">0</b> of 10,000</span></div>
<div class="prg-mb" role="meter" aria-label="Credits used" aria-valuemin="0" aria-valuemax="10000"><i class="fl"></i><i class="th" style="left:75%"></i><i class="th" style="left:90%"></i></div>
<div class="prg-ml"><span class="prg-mw"></span><span class="muted">Resets Dec 1</span></div>
</div>
<p class="prg-sr" aria-live="polite"></p>
</div>
</Fragment></div>
</div>
<script>
import './progress.js';
</script>
progress.htmlHTML · 22 lines
<div class="card" data-c="progress">
<div class="stage-in">
<div class="prg panel" data-s="ind">
<div class="prg-h">
<span class="prg-ring" role="progressbar" aria-label="Run progress"><svg viewBox="0 0 36 36" aria-hidden="true"><circle class="tr" cx="18" cy="18" r="15"/><circle class="fl" cx="18" cy="18" r="15" pathLength="100"/></svg><span class="prg-pc num"></span></span>
<div class="prg-t"><b>Indexing the docs</b><span class="muted prg-vt">Preparing the run</span></div>
<span class="tag prg-st">Starting</span>
<button class="ib prg-btn" aria-label="Pause" data-tip="Pause"><svg class="i s"><use href="#i-pause"/></svg></button>
</div>
<div class="prg-seg" role="progressbar" aria-label="Steps" aria-valuemin="0" aria-valuemax="5"><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i><i><b></b></i></div>
<div class="prg-ll"></div>
<div class="prg-lin ind" role="progressbar" aria-label="This step" aria-valuemin="0" aria-valuemax="100"><i></i></div>
<div class="prg-meter">
<div class="prg-mh"><span>Credits this month</span><span class="num muted"><b class="prg-mv">0</b> of 10,000</span></div>
<div class="prg-mb" role="meter" aria-label="Credits used" aria-valuemin="0" aria-valuemax="10000"><i class="fl"></i><i class="th" style="left:75%"></i><i class="th" style="left:90%"></i></div>
<div class="prg-ml"><span class="prg-mw"></span><span class="muted">Resets Dec 1</span></div>
</div>
<p class="prg-sr" aria-live="polite"></p>
</div>
</div>
</div>
progress.cssHTML · 48 lines
/* ---------- Progress and meter ---------- */
/* one run, three ways to read it: the ring is the whole run, the segments its steps (n of m), the bar the step in hand.
Its colour says how it is going: the accent while it runs, grey while paused, red when a step fails, green once done */
.prg{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-3);--dm-prg-c:var(--dm-accent-mark,var(--dm-accent))}
.prg[data-s="paused"]{--dm-prg-c:var(--dm-faint)}
.prg[data-s="err"]{--dm-prg-c:var(--dm-bad)}
.prg[data-s="done"]{--dm-prg-c:var(--dm-ok-mark)}
.prg-h{display:flex;align-items:center;gap:var(--dm-s-3)}
.prg-ring{position:relative;width:44px;height:44px;flex:none;display:grid;place-items:center;color:var(--dm-ok-mark)}
.prg-ring>svg{position:absolute;inset:0;width:100%;height:100%;transform:rotate(-90deg)}
.prg-ring circle{fill:none;stroke-width:3}
.prg-ring .tr{stroke:var(--dm-chip)}
.prg-ring .fl{stroke:var(--dm-prg-c);stroke-dasharray:0 100;stroke-linecap:round;transition:stroke-dasharray .6s var(--dm-ease),stroke .3s}
/* not knowing how long it will take, the ring turns a short arc and the bar sweeps */
.prg[data-s="ind"] .prg-ring>svg{animation:rot 1.1s linear infinite}
.prg[data-s="ind"] .prg-ring .fl{stroke-dasharray:25 100;transition:none}
.prg-pc{font-size:13px;font-weight:500;color:var(--dm-fg);line-height:1;letter-spacing:-.02em}
.prg-t{flex:1;min-width:0;display:flex;flex-direction:column;font-size:13.5px;line-height:1.4}
.prg-t b{font-weight:500}
.prg-vt{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.prg-st{flex:none}
.prg-st.run{background:var(--dm-accent-bg);color:var(--dm-accent-fg,var(--dm-accent))}
.prg-seg{display:flex;gap:var(--dm-s-1)}
.prg-seg i,.prg-lin{position:relative;flex:none;height:6px;border-radius:calc(3px * var(--dm-rk));background:var(--dm-chip);overflow:hidden}
.prg-seg i{flex:1}
.prg-seg b{position:absolute;inset:0;border-radius:inherit;background:var(--dm-prg-c);transform-origin:left;transform:scaleX(var(--dm-f,0));transition:transform .45s var(--dm-ease),background .3s}
.prg-ll{display:flex;align-items:center;justify-content:space-between;gap:var(--dm-s-2);min-height:28px;margin-bottom:calc(var(--dm-s-1) * -1);font-size:12.5px;color:var(--dm-fg2)}
.prg-ll .n{color:var(--dm-muted)}
.prg-ll .e{display:flex;align-items:center;gap:6px;min-width:0;color:var(--dm-bad)}
.prg-ll .e span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.prg-lin i{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:inherit;background:var(--dm-prg-c);transition:width .45s var(--dm-ease),background .3s}
.prg-lin.ind i{width:32%;animation:prgsweep 1.3s var(--dm-ease-io) infinite;transition:none}
@keyframes prgsweep{from{transform:translateX(-100%)}to{transform:translateX(320%)}}
.prg-meter{display:flex;flex-direction:column;gap:var(--dm-s-2);margin-top:var(--dm-s-1);padding-top:var(--dm-s-4);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.prg-mh{display:flex;align-items:baseline;justify-content:space-between;gap:var(--dm-s-2);font-size:13px}
.prg-mh b{font-weight:500;color:var(--dm-fg)}
.prg-mh .muted{font-size:12.5px}
.prg-mb{position:relative;height:8px;border-radius:calc(4px * var(--dm-rk));background:var(--dm-chip)}
.prg-mb .fl{position:absolute;left:0;top:0;bottom:0;width:0;border-radius:inherit;background:var(--dm-accent-mark,var(--dm-accent));transition:width .6s var(--dm-ease),background .3s}
.prg-mb.warn .fl{background:var(--dm-warn-mark)}
.prg-mb.bad .fl{background:var(--dm-bad)}
/* where the meter starts to warn: a notch through the bar at 75% and 90% */
.prg-mb .th{position:absolute;top:-3px;bottom:-3px;width:2px;margin-left:-1px;border-radius:1px;background:color-mix(in srgb,var(--dm-fg) 35%,transparent)}
.prg-ml{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:var(--dm-s-1) var(--dm-s-2);min-height:20px;font-size:12px}
.prg-ml .tag .i{margin-left:-1px}
.prg-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
@media (prefers-reduced-motion:reduce){.prg[data-s="ind"] .prg-ring>svg{animation:none}.prg-lin.ind i{animation:none;width:100%;opacity:.35}}
progress.jsHTML · 80 lines
DM.def('progress', (r, c) => {
r.innerHTML = r.innerHTML.replace('{pause}', I('pause'));
const root = $('.prg', r), ring = $('.prg-ring', r), arc = $('.prg-ring .fl', r), pc = $('.prg-pc', r), vt = $('.prg-vt', r), tag = $('.prg-st', r), btn = $('.prg-btn', r);
const segBox = $('.prg-seg', r), segs = $$('.prg-seg b', r), ll = $('.prg-ll', r), lin = $('.prg-lin', r), bar = $('.prg-lin i', r), sr = $('.prg-sr', r);
const mb = $('.prg-mb', r), mfl = $('.prg-mb .fl', r), mv = $('.prg-mv', r), mw = $('.prg-mw', r);
// the run's steps: what each does and what it counts
const S = [['Reading the sitemap', 'Pages', 48], ['Fetching pages', 'Pages', 48], ['Indexing files', 'Files', 2000], ['Writing embeddings', 'Chunks', 6400], ['Checking links', 'Links', 310]];
const LIMIT = 10000, fmt = n => Math.round(n).toLocaleString('en-US');
let state = 'ind', step = 0, sub = 0, secs = 0, credits = 7000, run = 0, failed = false;
const TAG = { ind:['Starting', ''], run:['Running', 'run'], paused:['Paused', ''], err:['Failed', 'bad'], done:['Done', 'ok'] };
let shownState = '', shownBand = -1;
function paint(){
const all = state === 'done' ? 1 : (step + sub) / S.length, [name, unit, total] = S[Math.min(step, S.length - 1)];
root.dataset.s = state;
// the ring: the whole run
if (state === 'ind'){ ring.removeAttribute('aria-valuenow'); ring.setAttribute('aria-valuetext', 'Preparing the run'); pc.textContent = ''; arc.style.strokeDasharray = ''; }
else { const p = Math.round(all * 100); ring.setAttribute('aria-valuenow', p); ring.setAttribute('aria-valuemin', 0); ring.setAttribute('aria-valuemax', 100); ring.setAttribute('aria-valuetext', p + '% of the run'); arc.style.strokeDasharray = `${all * 100} 100`;
if (state === 'done'){ if (!pc.querySelector('.i')){ pc.innerHTML = I('check','xs'); enter(pc.firstElementChild); } } else pc.textContent = p + '%'; }
// the segments: step n of m
segs.forEach((b, j) => b.style.setProperty('--dm-f', state === 'done' || j < step ? 1 : j === step && state !== 'ind' ? sub : 0));
segBox.setAttribute('aria-valuenow', state === 'done' ? S.length : step); segBox.setAttribute('aria-valuetext', state === 'done' ? 'All 5 steps done' : state === 'ind' ? 'Not started' : `Step ${step + 1} of ${S.length}`);
// the bar: the step in hand
lin.classList.toggle('ind', state === 'ind');
const sp = state === 'done' ? 1 : sub;
bar.style.width = state === 'ind' ? '' : sp * 100 + '%';
if (state === 'ind'){ lin.removeAttribute('aria-valuenow'); lin.setAttribute('aria-valuetext', 'Working out how much there is'); }
else { lin.setAttribute('aria-valuenow', Math.round(sp * 100)); lin.setAttribute('aria-valuetext', `${fmt(sp * total)} of ${fmt(total)} ${unit.toLowerCase()}`); }
// the line over the bar: the count, or what failed and a way on
if (state === 'err'){ if (shownState !== 'err'){ ll.innerHTML = `<span class="e">${I('alert','xs')}<span>The embeddings service timed out</span></span><button class="pill line sm prg-retry">${I('refresh','xs')}Retry</button>`; enter(ll.firstElementChild); $('.prg-retry', ll).onclick = retry; } }
else ll.innerHTML = state === 'ind' ? '<span>Getting ready</span><span class="n">Counting pages</span>' : `<span>${unit}</span><span class="n num">${fmt((state === 'done' ? 1 : sub) * total)} of ${fmt(total)}</span>`;
// the words: Step 3 of 5, indexing files
const words = { ind:'Preparing the run', run:`Step ${step + 1} of ${S.length}, ${name.toLowerCase()}`, paused:`Paused at step ${step + 1} of ${S.length}`, err:`Failed at step ${step + 1} of ${S.length}`, done:`Indexed 2,000 files in 0:${String(secs).padStart(2, '0')}` }[state];
if (vt.textContent !== words) vt.textContent = words;
if (shownState !== state){
swap(tag, TAG[state][0]); tag.className = 'tag prg-st ' + TAG[state][1];
const b = state === 'paused' ? ['play', 'Resume'] : state === 'done' ? ['refresh', 'Run again'] : ['pause', 'Pause'];
btn.innerHTML = I(b[0]); btn.setAttribute('aria-label', b[1]); btn.dataset.tip = b[1]; btn.disabled = state === 'err' || state === 'ind'; btn.style.visibility = state === 'err' ? 'hidden' : '';
// only a change of state is announced, never every percent
if (shownState) sr.textContent = { run:'Running', paused:'Paused', err:'Step 4 failed: the embeddings service timed out', done:'Done. ' + words, ind:'Starting' }[state];
shownState = state;
}
// the meter: credits, warning in words from 75% and more strongly from 90%
const m = credits / LIMIT, band = m >= .9 ? 2 : m >= .75 ? 1 : 0;
mfl.style.width = Math.min(100, m * 100) + '%'; mv.textContent = fmt(credits);
mb.classList.toggle('warn', band === 1); mb.classList.toggle('bad', band === 2);
mb.setAttribute('aria-valuenow', Math.round(credits)); mb.setAttribute('aria-valuetext', `${fmt(credits)} of 10,000 credits` + ['', ', running low', ', almost out'][band]);
if (band !== shownBand){ mw.innerHTML = ['<span class="muted">On track for the month</span>', `<span class="tag warn">${I('warn','xs')}Running low</span>`, `<span class="tag bad">${I('alert','xs')}Almost out</span>`][band]; if (shownBand >= 0) enter(mw.firstElementChild); shownBand = band; }
}
function retry(){ if (state !== 'err') return; state = 'run'; paint(); }
btn.onclick = () => {
if (state === 'run'){ state = 'paused'; paint(); }
else if (state === 'paused'){ state = step || sub ? 'run' : 'ind'; paint(); }
else if (state === 'done') c.run(go);
};
c.every(1000, () => { if (state === 'run') secs++; });
const until = async f => { while (!f()) await c.wait(100); };
// the agent's run: it works out what there is, then goes step by step; the fourth step times out once
async function go(){
const id = ++run; state = 'ind'; step = 0; sub = 0; secs = 0; credits = 7000; failed = false; paint();
await c.wait(1800); if (id !== run) return;
state = 'run';
for (step = 0; step < S.length; step++){
sub = 0; paint();
for (let k = 1; k <= 12; k++){
await c.wait(150); await until(() => state !== 'paused'); if (id !== run) return;
if (step === 3 && k === 6 && !failed){ failed = true; state = 'err'; paint(); await until(() => state !== 'err'); if (id !== run) return; await c.wait(400); }
sub = k / 12; credits += 38; paint();
}
}
step = S.length - 1; state = 'done'; paint();
}
paint(); c.run(go);
// the preview's story: the fourth step fails, the person retries it, and the run finishes
if (r === c.st) c.story(async s => {
await s.until(() => state === 'err'); await s.beat(3);
await s.tap($('.prg-retry', r)); s.away();
await s.until(() => state === 'done'); await s.beat(4);
});
});
Questions
What people ask about it.
- How do I show a run I can't measure yet?
- Set
stateto "indeterminate". Switch it to "running" withstepandstepProgressonce you know how much there is. - Can I use only the meter, or only the bar?
- The component always draws the run. For a meter on its own, leave the run out of your page and copy the meter's markup and its classes; they don't depend on the rest.
- How do I change where the meter warns?
- Pass
warnAtanddangerAtas shares ofmax, for example 0.8 and 0.95. The notches move with them.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.