How do you make a CSS progress bar?
Reach for the native progress element when the bar reports real task progress. If the design needs full visual control, build it from a div track with a fill inside.
For reading progress on long pages, a scroll-linked bar built on animation-timeline works without any script.
| Method | Styling control | Screen reader support | Needs JavaScript |
|---|
| Progress element | Limited, differs by engine | Built in | To update value |
| Div track and fill | Full | Manual | To update width or variable |
| Scroll-linked bar | Full | Manual | No |
Default to the progress element. MDN lists it as Baseline widely available, and as available across browsers since July 2015, and its implicit ARIA role is progressbar, so support and semantics both come free.
Switch to the div bar when the fill needs a gradient or a label drawn right on it. Also switch if you’d rather not write the same styling twice for two browser engines.
Either way, you’ll write a few lines of JavaScript to push the current value into the markup. The scroll-linked bar skips that, but it only tracks scroll position.
Using and styling the HTML progress element
The progress element draws its bar from a value and a max attribute. Styling it is the annoying part, since Chrome, Safari and Firefox build the element differently and each wants its own selector.
- Reset native styling with appearance: none on the progress element.
- Set width, height and the track color on progress itself (this styles the track in Firefox).
- Style the track in Chrome and Safari with ::-webkit-progress-bar.
- Style the fill in Chrome and Safari with ::-webkit-progress-value.
- Style the fill in Firefox with ::-moz-progress-bar.
- Test the indeterminate state by removing the value attribute.
Value, max and the indeterminate state
Leave off the value attribute and the progress element goes indeterminate, meaning something is running and nobody knows how long it will take.
When you do set it, value is a number from 0 to max, or from 0 to 1 when max is omitted. The max attribute is a floating point number above 0 and defaults to 1. A min attribute isn’t allowed. The minimum is always 0.
The :indeterminate pseudo-class matches the value-less state. To turn a determinate bar back into an indeterminate one, remove the attribute with removeAttribute(‘value’), according to MDN.
MDN’s own ::-moz-progress-bar example sets the width of :indeterminate bars to 0, which is a handy way to drop the default moving fill and run your own loop.
Styling the track and fill per browser engine
In Chrome and Safari, ::-webkit-progress-bar styles the track and ::-webkit-progress-value styles the fill, both sitting inside a ::-webkit-progress-inner-element wrapper.
Firefox is simpler. The progress element itself is the track, and ::-moz-progress-bar styles the fill.
Sources disagree on the Firefox name. Some tutorials write ::-moz-progress-value, but MDN documents only ::-moz-progress-bar and flags it as non-standard and experimental.
Treat the split as a cross-browser compatibility test case and keep each vendor selector in its own rule. A selector list that contains a pseudo-element the engine does not recognize is dropped whole, so one combined rule styles neither browser.
Removing native appearance and rounding corners
appearance: none strips the operating system’s bar so your background, border and size rules apply. Without it, the browser keeps painting the native control.
Chrome and Safari treat track and fill as separate boxes, so border-radius has to go on progress, ::-webkit-progress-bar and ::-webkit-progress-value. Firefox needs the radius on the progress element, and on ::-moz-progress-bar too if the fill should keep rounded ends.
For a pill shape, a border radius generator lets you preview the corners before you paste the value into all three rules.
How to build a CSS progress bar from div elements
Nest a fill element inside a track element, then give the track overflow: hidden and a border radius. The fill’s size can come from a plain width percentage or from a custom property that CSS reads back with var().
<div class="bar" role="progressbar" aria-label="Upload" aria-valuenow="40">
<div class="bar-fill" style="--p: 0.4"></div>
</div>
.bar { height: 10px; background: #e2e8f0; border-radius: 999px; overflow: hidden; }
.bar-fill { height: 100%; background: #2563eb; transform: scaleX(var(--p)); transform-origin: left; transition: transform 0.3s ease; }
A width percentage is the simplest option, one inline style or class per value. A custom property gets tidier as the component grows, since a script sets –p once and CSS reads it with var(–p) anywhere inside. The code above uses transform: scaleX(), which gives the same visual result at a lower animation cost.
web.dev’s animation guide steers authors toward transform and opacity because they stay on the compositing stage, and away from any property that triggers layout or paint.
Its demo has numbers for this. Animating top and left dropped 50% of frames, while the transform version dropped 1%. Width belongs to the same layout-triggering group as top and left, so scaleX wins when the bar updates often.
The catch is distortion. scaleX stretches everything inside the fill, including text and rounded ends, so keep labels in a sibling element and let the track’s overflow: hidden shape the ends.
Percentage text works best as a separate element positioned over the track or placed beside it. Coloring the bar by value, low versus high for example, needs a class switched in the same script step that updates the bar.
A linear-gradient fill and an inset box-shadow on the track add depth, and a gradient generator speeds up picking the color stops.
How to make a CSS progress bar accessible
A native progress element already exposes the progressbar role, so all it needs from you is an accessible name. A div bar gets nothing for free. You add the role and the value attributes yourself, plus a name.
MDN recommends the native element over the role for exactly that reason. Every behavior a div bar needs has to be rebuilt by hand with ARIA attributes, with script and CSS to match.
- role=”progressbar” goes on the track element
- aria-valuenow holds the current value and changes with every visual update, but it’s left out when the state is indeterminate
- aria-valuemin and aria-valuemax default to 0 and 100, so set them only when your range differs
- aria-valuetext carries a readable phrase such as “Step 2 of 5” when a percentage would mislead
- aria-label or aria-labelledby supplies the accessible name, and it’s required
On the native element, a label element or aria-label does the naming. Text between the progress tags doesn’t count, MDN notes, since it’s only a fallback for old browsers.
For a section that loads in the background, point aria-describedby at the bar and set aria-busy=”true” on the section until it finishes.
For long tasks, announce milestones such as halfway and done through a status message (role=”status”). WCAG 2.2 Success Criterion 4.1.3 Status Messages (Level AA) asks for exactly that: updates that assistive technology can present without moving focus. Rewriting aria-valuetext on every tick only adds noise.
Contrast is the other half of it. W3C’s WCAG 2.2 Understanding docs set a 3:1 minimum color contrast ratio for the visual parts of an interface component and its states (Success Criterion 1.4.11, Level AA).
In a progress bar, that covers the fill against the track and the track against the page. The criterion exempts components whose look the browser sets and the author never changes, so an unstyled progress element passes by default and a restyled one becomes your job to check.
Probably the most common failure on custom bars is a styled div with no role, which a screen reader never announces as a progress bar. And anything nested inside a role=”progressbar” element is flattened to plain text, so keep links and headings out of it.
Animating a CSS progress bar: transitions, stripes and indeterminate loops
Transitions handle value changes. Keyframes handle stripes and indeterminate loops, and a prefers-reduced-motion rule switches the decorative motion off.
For value changes, put a transition on the fill, around 300 ms with an ease-out curve, so a jump from 40% to 70% glides instead of snapping.
Stripes come from a repeating-linear-gradient at 45 degrees on the fill, with a keyframe animation that moves background-position by exactly one stripe width per loop. An indeterminate loop is a segment about 40% as wide as the track, translated across it on an infinite linear animation.
Both loops are plain CSS keyframes set to repeat. A stripe loop restarts cleanly only when the keyframe offset equals the size of one stripe cycle, and any other value shows a jump at every restart.
.bar-loop { position: relative; height: 10px; background: #e2e8f0; overflow: hidden; }
.bar-loop::before { content: ""; position: absolute; inset: 0 auto 0 0; width: 40%; background: #2563eb; animation: slide 1.2s linear infinite; }
@keyframes slide { from { transform: translateX(-100%); } to { transform: translateX(250%); } }
For a fill that plays once and stays full, add animation-fill-mode: forwards. Without it the bar snaps back to its starting state the moment the animation ends.
The prefers-reduced-motion query reports an operating system setting with two values, no-preference and reduce, according to web.dev. Vestibular reactions to motion range from dizziness and nausea to migraine headaches, so stripes and endless loops are the first things to remove.
Put the rule inside a media query for the reduce case and leave the static fill in place.
@media (prefers-reduced-motion: reduce) {
.bar-fill, .bar-loop::before { animation: none; }
}
An indeterminate bar with its loop removed shows nothing moving, so pair it with a visible text label such as Loading.
If script listens for animationend, shorten the duration to 1ms instead of using animation: none. That is the same trade-off web.dev’s forced-reduction snippet makes, because pages that wait for the event break otherwise.
Can a CSS progress bar track page scroll without JavaScript?
Yes, in browsers that support scroll-driven animations. Attach a scaleX keyframe to animation-timeline: scroll() on a fixed bar and it fills as the page scrolls, with no scroll listener.
@keyframes grow-progress { to { transform: scaleX(1); } }
.reading-bar {
position: fixed; top: 0; left: 0; width: 100%; height: 6px;
background: #2563eb;
transform: scaleX(0);
transform-origin: 0 50%;
}
@supports (animation-timeline: scroll()) {
.reading-bar {
animation: grow-progress auto linear;
animation-timeline: scroll();
}
}
The scroll() function takes two optional arguments, per Chrome for Developers. The scroller is nearest (default), root or self, and the axis is block (default), inline, x or y.
Leave animation-duration as auto, which is what omitting it gives you, because scroll position now drives the animation instead of time. Write animation-timeline after the animation shorthand too, since the shorthand resets it to auto (MDN).
The lookup is the tricky bit. Anonymous lookups follow the containing block chain, so an absolutely positioned bar can skip a scroller that is not its containing block.
A named timeline fixes the lookup problem. Set scroll-timeline-name on the scroller and reference the same name in animation-timeline.
Support has only just closed the gap. Chrome and Edge start at version 115, named in Chrome for Developers’ guide published May 5, 2023, and Safari 26, released in September 2025, has it too.
Firefox was the holdout. MDN’s Firefox 159 release notes say scroll-driven animations are now enabled by default, and Firefox 159 is scheduled for October 27, 2026. MDN’s status still reads Limited availability, because the Baseline data has not caught up with that release yet, and plenty of visitors will run older versions for a while.
Some summaries date Chrome support to 2025. Chrome for Developers published its scroll-driven animations guide on May 5, 2023 and already names Chrome 115 as the first version, so 2023 is the date to trust for Chrome.
Unsupported browsers need a quiet failure. The base rule above starts the bar at scaleX(0) and the animation attaches only inside @supports, so older browsers show no bar instead of a permanently full one.
view() works the same way for reveal effects but tracks an element’s visibility inside its scroller instead of the scroll position. More patterns live in this guide to CSS scroll effects.
Circular, vertical and step progress bars
Rings are the odd one out, since they run a conic-gradient() from a custom property instead of a plain fill. Vertical bars just swap the fill axis, and step bars use separate segments or a scroll-driven fill.
A ring needs a registered custom property so the browser can animate the angle.
@property --p { syntax: "<percentage>"; inherits: false; initial-value: 0%; }
.ring { position: relative; width: 96px; height: 96px; border-radius: 50%;
background: conic-gradient(#2563eb var(--p), #e2e8f0 0);
transition: --p 0.4s ease; }
.ring::after { content: ""; position: absolute; inset: 10px; border-radius: 50%; background: #fff; }
MDN marks @property as Baseline newly available since July 2024, and its own example animates a –progress value from 25% to 100% over 2.5 seconds to drive a gradient bar. conic-gradient() has been available across browsers since November 2020, per MDN.
The 0 on the second color stop forces a hard edge. A stop positioned lower than an earlier stop is raised to that earlier stop’s position, so both colors meet at a single point. The ::after disc punches the hole, so give it the page background color.
MDN warns that browsers pass no information about background images to assistive technology. A ring therefore needs the same role and value attributes as a div bar on its wrapper, or screen reader users get nothing.
Vertical bars follow the same pattern with the axis swapped. Give the track a fixed height, stretch the fill to the full height and scale it with transform: scaleY(var(–p)) from transform-origin: bottom.
For the native element, MDN’s guide to vertical form controls covers the writing-mode route.
Step bars drop the continuous fill. One element per step carries a done class, and the segments sit in a flex row with a small gap.
Chrome for Developers documents a scroll-driven version, a carousel indicator that starts at 1 divided by the number of images (33% for three) and reaches full width when the last image is in view. A named scroll timeline on the carousel’s scroll container drives it.
Pick segments when the steps are few and fixed. A stepper component fits better when each step needs its own label.
Limits of a CSS-only progress bar
CSS can’t measure anything. A CSS-only progress bar just displays a number that something else supplies.
It has no data source, for one. CSS cannot read upload, download or task state, so a script has to write the value or the –p variable.
The semantics go stale too. MDN says aria-valuenow should be provided and updated unless the bar is indeterminate, and CSS cannot write attributes.
Scroll-driven bars depend on animation-timeline, which only reached all three engines with Firefox 159, so older browsers get the fallback, which is no bar. And a keyframe fill with a fixed duration finishes whether or not the work does, which makes it fake progress.
The stale semantics have a direct consequence for the scroll-driven bar. With role=”progressbar” and no current value it reads as indeterminate, so treat it as decoration and mark it aria-hidden=”true” instead.
A timed fill is fine for a splash screen you control. For anything with real work behind it, it reports progress that does not exist.
| Situation | Better choice | Reason |
|---|
| Known percentage | Progress element or div bar | Value comes from real data |
| Unknown duration | Indeterminate loop or loader | No number to claim |
| Page layout still loading | Placeholder blocks | Shows structure, not progress |
| Position in a long article | Scroll-driven bar | Scroll position is the data |
When the wait has no measurable length, drop the percentage. Skeleton screens and low-quality image previews are common ways to make waits feel faster.
A CSS loader signals activity without claiming a number, which makes it the honest choice for an indeterminate wait.
A native progress element and a few lines of script cover most real cases. Anything fancier needs a reason.
CSS Progress Bar FAQ
How do you update the value of a progress bar with JavaScript?
Assign a number to the value property of the progress element, for example progress.value = 70, within the 0 to max range.
On a div bar, write the fraction to the –p custom property and the matching number to aria-valuenow in the same step.
Can the progress element display the percentage as text?
No. Text placed between the progress tags is only a fallback for old browsers, according to MDN, and it does not work as an accessible label either.
Put the number in a sibling span and update it together with the value.
How is the progress element different from the meter element?
Progress reports how far a task has gone, while meter shows a measurement inside a known range, such as disk usage.
Progress also has a fixed minimum of 0 and does not allow a min attribute, per MDN. Meter takes min, max, low, high and optimum, and the browser colors the bar by which region the value falls in.
Does Bootstrap have a ready-made progress bar?
Yes. Bootstrap ships a track and fill component with striped and animated modifiers.
This guide to the Bootstrap progress bar shows the markup and the class names.
How do you style a progress bar in Tailwind CSS?
Compose a track and fill from utility classes, because Tailwind has no progress component of its own.
For the native element, arbitrary variants such as [&::-webkit-progress-value] reach the vendor pseudo-elements, as these Tailwind progress bar examples show.
Which CSS Progress Bar Method to Use First
A CSS progress bar should start as the native progress element, with a div-based fill only where the design outgrows the vendor pseudo-elements.
The order that costs the least maintenance begins with the native progress element and its appearance reset. Next comes a div track and fill with hand-written ARIA, and last a scroll-linked fill behind an @supports check.
That sequence follows browser support. The native element has been available across browsers since July 2015, which is more than eleven years of coverage, while animation-timeline only reached its third engine in October 2026.
The trade-off sits with the div bar. It gives full styling control, and in return the role, the current value and the accessible name become the author’s job.
Firefox 159, scheduled for October 27, 2026, enables scroll-driven animations by default, so all three engines now have them. Keep the fallback for the scroll-linked bar anyway, since older browser versions stay in use for months. That position was checked on October 27, 2026.