Build a progress bar — the strip that fills up to show how far along an operation is. It looks trivial (a coloured div inside a grey one), but doing it right means two things: mapping a 0–100 number to the fill's width, and making it accessible, so a screen reader can announce "65%" instead of seeing a meaningless <div>. This is the base for the whole progress-bar family that follows.
// A self-contained component. No props.
function App(): JSX.Element;
A track with a coloured fill whose width reflects a percentage, plus a control to change it.
pct = 0 → fill width 0% (empty)
pct = 65 → fill width 65%
pct = 100 → fill width 100% (full)
Accessibility — the track is a progressbar:
role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}
A screen reader then announces e.g. "65 percent".
width is ${pct}%. Keep the value clamped to 0–100 so it never overflows the track.progressbar. A bare <div> tells assistive tech nothing. Add role="progressbar" and aria-valuenow/min/max so the value is announced.% label, and aria-valuenow all come from the same state value — never set them independently.You'll build a progress bar that's correct in both dimensions that matter: it looks right (a fill whose width tracks a value) and it reads right (a real progressbar an assistive technology can announce).
A progress bar is two nested boxes: a track and a fill. The fill's width is the percentage — at 65 the fill covers 65% of the track. The part people forget is that, visually, a coloured div conveys nothing to someone using a screen reader; you have to label it as a progress bar and tell it the current value. So the whole component is one number rendered two ways: as a width, and as an ARIA value.
Hold the percentage in state. Render the fill with width: ${pct}%. Mark the track with role="progressbar" and mirror the same number into aria-valuenow (with aria-valuemin={0} and aria-valuemax={100}). Everything visible and announced derives from that one value, so they can never disagree.
The quick version draws the bar but stops there:
function App() {
const [pct, setPct] = useState(65);
return (
<div className="progressbar">
<div className="progressbar-fill" style={{ width: `${pct}%` }} />
</div>
);
}
It looks correct, but to a screen reader it's just two anonymous <div>s — no role, no value, nothing announced. And if pct ever goes below 0 or above 100 (a bad prop, a rounding slip), the fill underflows or spills past the track. A progress bar that only works for sighted users and only for in-range values isn't done.
import { useState } from 'react';
import './styles.css';
// Keep the value in range so the fill never under/overflows.
const clamp = (n) => Math.max(0, Math.min(100, n));
export default function App() {
const [pct, setPct] = useState(65);
const value = clamp(pct);
return (
<main className="container">
<h1>Progress Bar</h1>
<div className="progress-demo">
<div
className="progressbar"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Operation progress"
>
<div className="progressbar-fill" style={{ width: `${value}%` }} />
</div>
<div className="progress-meta">
<span className="pct-label">{value}%</span>
</div>
<input
className="slider"
type="range"
min={0}
max={100}
value={pct}
onChange={(e) => setPct(Number(e.target.value))}
/>
</div>
</main>
);
}
The shifts: the track is now a real role="progressbar" carrying aria-valuenow/min/max, so the value is announced; a clamp guarantees the width stays within 0–100; and the width, the % label, and aria-valuenow all read from the same value, so they stay in lockstep.
Start at pct = 65, so value = clamp(65) = 65.
width: 65%; the label shows 65%; the track reports aria-valuenow={65}. A screen reader announces "65 percent".onChange fires setPct(90); re-render makes value = 90. Fill width, label, and aria-valuenow all become 90 together.setPct(130)). value = clamp(130) = 100, so the fill stops exactly at the track's edge and aria-valuenow reports 100 — no overflow.Because width, label, and ARIA all derive from the single clamped value, the visual and the announced state are always the same.
<div> is invisible to screen readers. Fix: role="progressbar" + aria-valuenow/min/max (and a label).width: 130% overflows the track; negative values vanish. Fix: clamp to 0–100.aria-valuenow from another lets them drift. Fix: derive both from one value.transition: width smooths changes (and sets up the next questions in this family).aria-valuenow and show a looping animation to signal "working".% centered over the fill, switching text colour as the fill passes it.This version stores the already clamped value through a reducer. The progress semantics and rendered UI remain identical.
import { useReducer } from 'react';
import './styles.css';
const clamp = (value: number) => Math.max(0, Math.min(100, value));
export default function App() {
const [value, setValue] = useReducer(
(_current: number, requested: number) => clamp(requested),
65,
);
return (
<main className="container">
<h1>Progress Bar</h1>
<div className="progress-demo">
<div
className="progressbar"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Operation progress"
>
<div className="progressbar-fill" style={{ width: `${value}%` }} />
</div>
<div className="progress-meta">
<span className="pct-label">{value}%</span>
</div>
<input
className="slider"
type="range"
min={0}
max={100}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
/>
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a progress bar — the strip that fills up to show how far along an operation is. It looks trivial (a coloured div inside a grey one), but doing it right means two things: mapping a 0–100 number to the fill's width, and making it accessible, so a screen reader can announce "65%" instead of seeing a meaningless <div>. This is the base for the whole progress-bar family that follows.
// A self-contained component. No props.
function App(): JSX.Element;
A track with a coloured fill whose width reflects a percentage, plus a control to change it.
pct = 0 → fill width 0% (empty)
pct = 65 → fill width 65%
pct = 100 → fill width 100% (full)
Accessibility — the track is a progressbar:
role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}
A screen reader then announces e.g. "65 percent".
width is ${pct}%. Keep the value clamped to 0–100 so it never overflows the track.progressbar. A bare <div> tells assistive tech nothing. Add role="progressbar" and aria-valuenow/min/max so the value is announced.% label, and aria-valuenow all come from the same state value — never set them independently.You'll build a progress bar that's correct in both dimensions that matter: it looks right (a fill whose width tracks a value) and it reads right (a real progressbar an assistive technology can announce).
A progress bar is two nested boxes: a track and a fill. The fill's width is the percentage — at 65 the fill covers 65% of the track. The part people forget is that, visually, a coloured div conveys nothing to someone using a screen reader; you have to label it as a progress bar and tell it the current value. So the whole component is one number rendered two ways: as a width, and as an ARIA value.
Hold the percentage in state. Render the fill with width: ${pct}%. Mark the track with role="progressbar" and mirror the same number into aria-valuenow (with aria-valuemin={0} and aria-valuemax={100}). Everything visible and announced derives from that one value, so they can never disagree.
The quick version draws the bar but stops there:
function App() {
const [pct, setPct] = useState(65);
return (
<div className="progressbar">
<div className="progressbar-fill" style={{ width: `${pct}%` }} />
</div>
);
}
It looks correct, but to a screen reader it's just two anonymous <div>s — no role, no value, nothing announced. And if pct ever goes below 0 or above 100 (a bad prop, a rounding slip), the fill underflows or spills past the track. A progress bar that only works for sighted users and only for in-range values isn't done.
import { useState } from 'react';
import './styles.css';
// Keep the value in range so the fill never under/overflows.
const clamp = (n) => Math.max(0, Math.min(100, n));
export default function App() {
const [pct, setPct] = useState(65);
const value = clamp(pct);
return (
<main className="container">
<h1>Progress Bar</h1>
<div className="progress-demo">
<div
className="progressbar"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Operation progress"
>
<div className="progressbar-fill" style={{ width: `${value}%` }} />
</div>
<div className="progress-meta">
<span className="pct-label">{value}%</span>
</div>
<input
className="slider"
type="range"
min={0}
max={100}
value={pct}
onChange={(e) => setPct(Number(e.target.value))}
/>
</div>
</main>
);
}
The shifts: the track is now a real role="progressbar" carrying aria-valuenow/min/max, so the value is announced; a clamp guarantees the width stays within 0–100; and the width, the % label, and aria-valuenow all read from the same value, so they stay in lockstep.
Start at pct = 65, so value = clamp(65) = 65.
width: 65%; the label shows 65%; the track reports aria-valuenow={65}. A screen reader announces "65 percent".onChange fires setPct(90); re-render makes value = 90. Fill width, label, and aria-valuenow all become 90 together.setPct(130)). value = clamp(130) = 100, so the fill stops exactly at the track's edge and aria-valuenow reports 100 — no overflow.Because width, label, and ARIA all derive from the single clamped value, the visual and the announced state are always the same.
<div> is invisible to screen readers. Fix: role="progressbar" + aria-valuenow/min/max (and a label).width: 130% overflows the track; negative values vanish. Fix: clamp to 0–100.aria-valuenow from another lets them drift. Fix: derive both from one value.transition: width smooths changes (and sets up the next questions in this family).aria-valuenow and show a looping animation to signal "working".% centered over the fill, switching text colour as the fill passes it.This version stores the already clamped value through a reducer. The progress semantics and rendered UI remain identical.
import { useReducer } from 'react';
import './styles.css';
const clamp = (value: number) => Math.max(0, Math.min(100, value));
export default function App() {
const [value, setValue] = useReducer(
(_current: number, requested: number) => clamp(requested),
65,
);
return (
<main className="container">
<h1>Progress Bar</h1>
<div className="progress-demo">
<div
className="progressbar"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Operation progress"
>
<div className="progressbar-fill" style={{ width: `${value}%` }} />
</div>
<div className="progress-meta">
<span className="pct-label">{value}%</span>
</div>
<input
className="slider"
type="range"
min={0}
max={100}
value={value}
onChange={(event) => setValue(Number(event.target.value))}
/>
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.