CSS Progress Bars

Animated progress bars built from the native <progress> element and CSS, with no JavaScript.

Published May 19, 2026 Intermediate 7 min read

A CSS progress bar starts with the <progress> element, which already carries a value, a maximum and the right role for assistive technology. Styling it means removing the native appearance and then painting the track and the fill through vendor pseudo-elements, which is where the pattern gets fiddly: the names differ between engines, and one of them means the opposite of what it looks like.

The bars below cover color variants and a shimmering fill. Two more versions follow: an indeterminate bar for work with no known end, and a plain <div> version driven by a single custom property, which trades the built in semantics for a value you can animate however you like.

Five bars, one element type

CSS
HTML
JS
Rust
Upload

HTML

<div class="progress-list">
  <div class="progress-row">
    <span class="progress-label">CSS</span>
    <progress value="92" max="100"></progress>
  </div>
  <div class="progress-row">
    <span class="progress-label">HTML</span>
    <progress class="success" value="85" max="100"></progress>
  </div>
  <div class="progress-row">
    <span class="progress-label">JS</span>
    <progress class="warning" value="45" max="100"></progress>
  </div>
  <div class="progress-row">
    <span class="progress-label">Rust</span>
    <progress class="danger" value="12" max="100"></progress>
  </div>
  <div class="progress-row">
    <span class="progress-label">Upload</span>
    <progress class="shimmer" value="65" max="100"></progress>
  </div>
</div>

CSS

.progress-list {
  width: 100%;
  max-width: 360px;
}

.progress-row {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin: .5rem 0;
}

.progress-label {
  font-size: .85rem;
  color: #88888f;
  width: 60px;
  flex-shrink: 0;
}

progress {
  -webkit-appearance: none;
  appearance: none;
  flex: 1;
  height: 10px;
  border: none;
  border-radius: 999px;
  overflow: hidden;
}

/* The track */
progress::-webkit-progress-bar {
  background: #1c1c1e;
  border-radius: 999px;
}

/* The fill. Firefox uses its own pseudo-element for this. */
progress::-webkit-progress-value {
  background: #b8ff57;
  border-radius: 999px;
  transition: width .3s;
}

progress::-moz-progress-bar {
  background: #b8ff57;
  border-radius: 999px;
}

progress.success::-webkit-progress-value { background: #57d9a3; }
progress.warning::-webkit-progress-value { background: #ff9040; }
progress.danger::-webkit-progress-value  { background: #ff6b6b; }

progress.success::-moz-progress-bar { background: #57d9a3; }
progress.warning::-moz-progress-bar { background: #ff9040; }
progress.danger::-moz-progress-bar  { background: #ff6b6b; }

progress.shimmer::-webkit-progress-value {
  background: linear-gradient(90deg,
    #b8ff57 30%,
    rgba(255, 255, 255, .3) 50%,
    #b8ff57 70%
  );
  background-size: 200% 100%;
  animation: progress-shimmer 1.4s linear infinite;
}

progress.shimmer::-moz-progress-bar {
  background: linear-gradient(90deg,
    #b8ff57 30%,
    rgba(255, 255, 255, .3) 50%,
    #b8ff57 70%
  );
  background-size: 200% 100%;
  animation: progress-shimmer 1.4s linear infinite;
}

@keyframes progress-shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}

Other ways to build it

Indeterminate, for work with no known end

Removing the value attribute puts the element into its indeterminate state, which browsers announce as busy with no duration. The native animation differs by platform and is not stylable, so the practical approach is to hide the fill and paint a moving band on the track instead. :indeterminate is what scopes those rules, so the same class can hold either state and the stylesheet decides which rendering applies.

Sync
Import
Done

HTML

<!-- no value attribute: the element is indeterminate -->
<progress class="pb-indet" max="100"></progress>

<!-- the same class, with a value: an ordinary bar -->
<progress class="pb-indet" value="100" max="100"></progress>

CSS

/* the track carries the moving band, because the fill has no
   width to work with while the element is indeterminate */
.pb-indet:indeterminate::-webkit-progress-bar {
  background:
    linear-gradient(90deg, transparent, #b8ff57, transparent)
      0 0 / 40% 100% no-repeat,
    #1c1c1e;
  animation: pb-sweep 1.3s linear infinite;
}

/* a separate rule: a shared selector list would be invalid in both */
.pb-indet:indeterminate::-moz-progress-bar {
  background: transparent;
}

.pb-indet:indeterminate {
  background:
    linear-gradient(90deg, transparent, #b8ff57, transparent)
      0 0 / 40% 100% no-repeat,
    #1c1c1e;
  animation: pb-sweep 1.3s linear infinite;
}

@keyframes pb-sweep {
  from { background-position: -45% 0; }
  to   { background-position: 105% 0; }
}

A div, one custom property, and a segmented track

Dropping <progress> costs the built in role and gains complete control of the painting. One --pct value on the wrapper drives the fill width, and it is the same number aria-valuenow reports, so the visual and the announced value cannot drift apart. The segments are a repeating-linear-gradient painted over the top rather than a row of elements, which means the count follows the width instead of the markup. Because the semantics are no longer free, the wrapper carries role="progressbar" and the three aria value attributes by hand.

Rendering82%
Encoding46%
Uploading13%

HTML

<div class="pb-track pb-seg" role="progressbar" aria-label="Rendering"
     aria-valuenow="82" aria-valuemin="0" aria-valuemax="100"
     style="--pct: 82%">
  <div class="pb-fill"></div>
</div>

CSS

.pb-track {
  position: relative;
  height: 12px;
  border-radius: 999px;
  background: #1c1c1e;
  overflow: hidden;
}

.pb-fill {
  width: var(--pct, 0%);
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, #38bdf8, #b8ff57);
  transition: width .45s ease;
}

/* the segments are painted over the top, not built from elements */
.pb-seg::after {
  content: "";
  position: absolute;
  inset: 0;
  background: repeating-linear-gradient(90deg,
    transparent 0 14px, #0c0c0d 14px 17px);
  pointer-events: none;
}

How it works

The native <progress> element is styled through vendor prefixed pseudo-elements. appearance: none removes the platform rendering first. ::-webkit-progress-bar styles the track and ::-webkit-progress-value styles the filled portion. Firefox exposes only ::-moz-progress-bar, and that one is the fill rather than the track. The color variants are class based overrides on those same pseudo-elements.

<progress> is a replaced element with a browser built shadow tree, so ordinary properties do not reach its parts. appearance: none is the first step, and without it Chromium and Safari keep drawing the platform bar underneath whatever you add. The second step is knowing which pseudo-element is which: in Chromium and Safari, ::-webkit-progress-bar is the track and ::-webkit-progress-value is the fill, while Firefox has no track pseudo-element at all and ::-moz-progress-bar is the fill. The Firefox track is styled on the progress element itself.

Those rules cannot share a selector list. A selector list is invalid as a whole if any part of it is unrecognised, so writing progress::-webkit-progress-value, progress::-moz-progress-bar { } means the browser that understands only one of them throws away the rule entirely, including the half it did understand. That is why the stylesheet on this page repeats every fill declaration in two separate blocks. It looks like duplication and it is not optional.

max defaults to 1. A <progress value="65"> with no max is therefore already complete and then some, and it renders full, which is the most common reason a bar looks stuck at 100 percent. Setting max="100" alongside a percentage value is the fix. Removing the value attribute entirely is a different state again: the element becomes indeterminate, which is what the first variant below shows.

The shimmer is a gradient wider than the element with an animated background-position. Two colors that differ only slightly will animate perfectly and remain invisible, so the highlight has to have real contrast against the fill underneath it. That failure mode is the same one described on CSS skeleton loader, where a shimmer that ran correctly could not be seen at all.

A progress bar does not have to be an input at all. The thin line across the very top of this page is a <div> whose scaleX is driven by animation-timeline: scroll(root block), so it reports reading position rather than a task, with no value attribute and no script. That technique is covered on CSS scroll reveal.

CSS properties used

appearance
none removes the platform rendering of the element. Without it the native bar keeps drawing underneath anything you paint on top.
::-webkit-progress-bar
The track, in Chromium and Safari. Firefox has no equivalent, and its track is the progress element itself.
::-webkit-progress-value
The filled portion, in Chromium and Safari. It accepts a transition, so a value change can animate.
::-moz-progress-bar
The fill in Firefox, despite the name suggesting the track. It has to live in its own rule, never in a shared selector list.
background-position
Animated across an oversized gradient, it is what moves the shimmer band along the fill.
custom properties
The div based version's only input. One --pct value drives the width, and everything else reads from it.

Browser support

FeatureChromeFirefoxSafariEdge
progress86612
appearance838015.483
:indeterminate395110.179
animation455.112
custom properties49311016

Figures come from Can I Use for the progress element, CSS Appearance, the :indeterminate pseudo-class, CSS Animation and CSS Variables. The appearance row is the one to read carefully: the unprefixed property is recent, so keep -webkit-appearance: none alongside it, which is what the stylesheet here does. The ::-webkit- and ::-moz- pseudo-elements are vendor extensions rather than standard features and have no Can I Use entries of their own, so they are not in the table. There is no standard replacement yet, which is the main argument for the <div> based version.

Accessibility notes

<progress> comes with an implicit role="progressbar" and exposes its value and maximum without any ARIA at all, which is the strongest reason to start with it. Remove the value attribute and the element is announced as indeterminate, which is exactly right for work with no known end. A <div> gets none of this, so the second variant carries role="progressbar" with aria-valuenow, aria-valuemin and aria-valuemax written out by hand.

The element has no accessible name of its own. A bar sitting next to the word "CSS" is meaningful to someone looking at it and anonymous to a screen reader, so connect the two with a <label> or point at the visible text with aria-labelledby. Without that, a reader hears a series of percentages with nothing to attach them to.

Color alone should not carry the meaning. The red bar in the demo above reads as trouble only to a sighted reader, so put the state in text next to the bar as well. Progress that updates faster than about once a second is also worth throttling for screen reader announcements, since a live region firing continuously drowns out everything else on the page.

What you can build with it

  • Skill and rating bars. Static values on a profile or a comparison page, where the bar is a picture of a number that is already in the text.
  • Upload and download status. A determinate bar whose value updates as bytes arrive, with the fill transitioned so it moves smoothly between steps.
  • Multi step forms. Step three of five as a bar, using max to match the step count rather than converting to a percentage.
  • Indeterminate waiting. A <progress> with no value, for a request whose duration is unknown, which is the first variant below.
  • Reading position. The line at the top of this page, driven by scroll position rather than by a task, which needs no value at all.

Mistakes worth avoiding

  • Leaving max off. It defaults to 1, so any value above one renders as a full bar and the progress appears frozen at complete.
  • Putting the -webkit- and -moz- pseudo-elements in one selector list. An unrecognised selector invalidates the whole list, so both browsers drop the rule and neither bar gets styled.
  • Skipping appearance: none. The platform bar keeps rendering underneath, so the custom colors appear over the top of a native widget rather than instead of it.
  • Expecting ::-moz-progress-bar to be the track. It is the fill. The Firefox track is the progress element itself, which catches people who style by name rather than by testing.
  • Leaving the bar unlabelled. <progress> has a role and a value but no name, so it is announced as a bare percentage with no indication of what is progressing.

Frequently asked questions

How do I style the HTML progress element with CSS?
Set -webkit-appearance: none and appearance: none first, then style ::-webkit-progress-bar for the track and ::-webkit-progress-value for the fill. Add a separate rule for ::-moz-progress-bar, which is the fill in Firefox. Keep the two vendor rules apart, because a shared selector list is invalid in both.
Why is my progress bar always full?
The max attribute is missing. It defaults to 1, so value="65" is read as sixty five times the maximum and clamps to complete. Add max="100" when your values are percentages.
How do I make an indeterminate progress bar?
Leave the value attribute off. The element then matches :indeterminate, and browsers announce it as busy with no known duration. The native animation varies by platform, so most designs add their own sweep with @keyframes and a gradient.
Should I use a progress element or a div?
Start with <progress>. It brings the role, the value and the indeterminate state for free. Move to a <div> when the design needs something the vendor pseudo-elements cannot do, such as a segmented track or a value that animates through a custom property, and add role="progressbar" with the three aria value attributes by hand.
Can I animate a progress bar fill?
Yes. ::-webkit-progress-value accepts a transition, so a value change eases rather than jumping. For the shimmer effect, animate background-position across a gradient wider than the element, and make sure the highlight actually contrasts with the fill or the animation will run invisibly.