Tailwind progress bar markup
Two divs and a handful of utility classes are enough, with no custom stylesheet. The outer div is the track and holds the empty state. The inner div is the fill, and its width is whatever share is done.
<div class="w-full bg-gray-200 rounded-full h-2.5">
<div class="bg-blue-600 h-2.5 rounded-full" style="width: 45%"></div>
</div>
The track gets w-full, h-2.5, bg-gray-200 and rounded-full. The fill repeats the height and radius, takes a stronger color such as bg-blue-600, and carries the width. Add overflow-hidden to the track so the fill stays inside the rounded ends.
On Tailwind’s default spacing scale, h-2.5 works out to 0.625rem, or 10px (Tailwind CSS docs). Go down to h-1.5 for a thin bar, or up to h-4 when a label sits inside.
Flowbite and Material Tailwind both ship this same two-div structure in their progress docs, and Flowbite sets its 45% example with an inline style.
Both layers are plain HTML markup, so the snippet drops into a Vue template or a JSX file unchanged.
For the utility-class basics behind these names, see how Tailwind handles styling.
How to set the progress bar width
What separates the options is when the value is known. A fraction like w-1/2 or an arbitrary value like w-[45%] is fixed at build time. An inline style="width: 45%" or a CSS variable read through w-(--progress) takes values that only exist at runtime.
| Method | Syntax | Value known | Limit |
|---|
| Fraction | w-1/2 | Build time | Fixed steps only |
| Arbitrary value | w-[45%] | Build time | One full class per value |
| Inline style | style="width: 45%" | Runtime | No variants such as md: |
| CSS variable | w-(--progress) | Runtime | Variable must be set in style |
The familiar fractions land on steps such as 20%, 25%, 33%, 50% and 75%. In v4 any fraction compiles, so w-37/100 gives 37%, but it is still one fixed class per value, and a bar driven by live data needs one of the runtime methods.
Tailwind’s sizing docs describe w-(--my-width) as shorthand for w-[var(--my-width)], so the variable route costs nothing extra to write.
Put breakpoint prefixes on the track, never on the fill. A prefixed fill changes the reported progress at certain screen sizes, while a prefixed track such as md:w-1/2 only changes how wide the whole bar renders.
That second case is the useful one when the page layout is responsive by design.
Styling the bar with color, size, labels, dark mode and animation
Height, radius and color utilities on the two divs change the look. A width transition with a duration adds motion, and dark: variants go on both layers.
Color, height and labels
h-1.5 gives a thin bar, h-2.5 is the common default, and h-4 leaves room for text inside. Material Tailwind ships 3 preset sizes. Any bg-{color} utility works on the fill, according to the Flowbite docs.
Labels can sit inside the fill, or outside in a flex row with justify-between, the name on the left and the percentage on the right.
For dark mode, dark:bg-gray-700 on the track and a lighter fill shade such as dark:bg-cyan-500 is a common pairing.
A label inside the fill falls apart at low values, because a 4% fill is narrower than its own text. Render the label outside the track below a threshold, or just always render it outside. I’d do the second.
Pick a fill and track pair with a clear contrast between colors, since a pale fill on a pale track disappears for low-vision users.
Width transitions
Tailwind’s transition utilities default to 150ms with a cubic-bezier(0.4, 0, 0.2, 1) easing (Tailwind CSS docs).
That is quick for a bar jumping from 20% to 80%. Many copy-paste snippets add transition-all duration-500 to the fill.
<div class="bg-blue-600 h-4 rounded-full transition-[width] duration-500" style="width: 80%"></div>
The arbitrary property syntax, shown in the docs for transition-[height], limits the transition to width. A color flip at a threshold then snaps instead of fading.
One thing trips people up. A bar that mounts at 70% never animates, because a CSS transition needs a change in value. Mount at 0 and set the real value on the next frame.
Add motion-reduce:transition-none for visitors who ask for less motion (Tailwind CSS docs).
Striped and indeterminate bars
Core Tailwind ships no striped utility (the background docs list none), so a repeating gradient layered on the fill does the job instead.
Indeterminate bars hit a similar gap. The default animation utilities are spin, ping, pulse and bounce. animate-pulse on the fill signals loading, but nothing slides across the track, and a sliding bar needs custom keyframes.
When the wait has no measurable end, a spinner built with Tailwind is simpler than faking a sliding bar.
Updating the width from state in React, Vue and Alpine.js
Keep the percentage in reactive state and bind it to the fill’s inline width. React takes ` style={{ width: ${progress}% }} , Vue takes :style=”{ width: progress + ‘%’ }”, and Alpine.js uses x-bind:style`.
Binding progress state
In React, pass a number prop and set the fill’s style width from it. Yogini Bende’s dev.to walkthrough also flips the fill from bg-red-600 to bg-green-600 at 70%. Vue binds with :style on the fill div.
Alpine.js puts x-bind:style="'width: ' + progress + '%;'" on the fill and x-text on the label. Mike Andreuzza has a dev.to tutorial on the same bar, and Pines UI ships a ready-made Alpine and Tailwind progress component.
Clamp the value before binding. A progress of 112 renders a fill wider than the track unless the track has overflow-hidden.
const pct = Math.min(100, Math.max(0, progress));
<div className="h-2.5 w-full rounded-full bg-gray-200">
<div className="h-2.5 rounded-full bg-blue-600" style={{ width: `${pct}%` }} />
</div>
Projects without a working setup can start with getting Tailwind CSS running in a React project.
Why dynamic width classes break in production
Tailwind reads source files as plain text and never parses them as code (Tailwind CSS docs, as of October 2026). A class built with interpolation, like w-[${progress}%], never exists as a complete string, so no CSS gets generated for it.
It can look fine in development. The daisyUI blog explains that a class saved earlier can linger in the dev CSS, then vanish from the production build.
This pattern breaks:
className={`w-[${progress}%]`}
This one holds, because the class is complete and the value lives in a variable:
<div class="h-2.5 rounded-full bg-blue-600 w-(--progress)" style="--progress: 45%"></div>
For a finite set like color variants, map props to complete class names. Tailwind v4 forces extra classes with @source inline(), and v3 uses the safelist option.
Continuous values, like 0 to 100, are different. Use an inline style or a CSS variable. Hyva’s docs name CSS variables as the recommended fix for dynamic values.
Read together, Tailwind’s docs and Hyva’s guidance split the problem cleanly: class maps for choices, variables for numbers.
A safelist for every percentage works too, but it adds up to 101 width classes to the CSS for a value one variable carries.
How to make a Tailwind progress bar accessible
Put role="progressbar" on the track, give it an accessible name, and keep aria-valuenow in sync with the fill width. The fill stays purely visual. Minimum and maximum only need setting when the range is not 0 to 100.
<div role="progressbar" aria-label="Upload progress" aria-valuenow="45"
class="w-full h-2.5 rounded-full bg-gray-200">
<div class="h-2.5 rounded-full bg-blue-600" style="width: 45%"></div>
</div>
MDN’s progressbar reference covers the attributes this markup relies on. aria-valuemin falls back to 0 and aria-valuemax to 100 when omitted. aria-valuenow is required, except on an indeterminate bar, where the attribute is left out. An accessible name through aria-label or aria-labelledby is required too.
These attributes belong to ARIA, and assistive technology usually reads aria-valuenow back as a percentage. A stale value is a wrong announcement, not a cosmetic bug.
Bind aria-valuenow and the width to the same state value. Two separate variables drift apart.
An indeterminate bar that keeps aria-valuenow="0" announces 0%, which is the opposite of what it means. Remove the attribute instead.
When a percentage misleads, as on a wizard bar that reads “Step 2 of 5”, add aria-valuetext with the readable phrase.
Browsers treat everything inside a progressbar as presentational, so a heading or link placed inside the track loses its meaning. Keep the visible label outside the track and point to it with aria-labelledby.
For a bar that reports a page region loading, set aria-busy="true" on the region and reference the bar with aria-describedby until loading finishes.
Contrast and focus rules still apply around the bar, and the wider web accessibility checklist covers them.
Styling the native progress element with Tailwind
MDN lists the progress element as Baseline widely available, with support across browsers since July 2015.
It already carries the progressbar role, so no ARIA is needed. Tailwind only has to reach its inner parts, and that is where the engines split.
| Part | Chrome and Safari | Firefox |
|---|
| Track | ::-webkit-progress-bar | The progress element itself |
| Fill | ::-webkit-progress-value | ::-moz-progress-bar |
Chrome and Safari nest the fill inside a separate track, while Firefox exposes only the fill, per romanzipp’s January 2025 guide.
That split is a cross-browser compatibility problem in miniature. Every selector needs a twin.
<progress
class="h-2.5 w-56 appearance-none overflow-hidden rounded-full
[&::-webkit-progress-bar]:bg-slate-300
[&::-webkit-progress-value]:bg-green-400
[&::-moz-progress-bar]:bg-green-400"
value="45" max="100"></progress>
The appearance reset comes first. MDN states that ::-webkit-progress-value takes no effect until appearance is set to none.
The max attribute defaults to 1, so value="45" without max="100" is out of range. Text between the tags is fallback content for old browsers, not an accessible label, so use a label element or aria-labelledby.
For an indeterminate bar, drop the value attribute and the :indeterminate pseudo-class matches. An arbitrary variant such as [&:indeterminate]:bg-gray-300 can then restyle it.
The same pseudo-elements drive any plain CSS progress bar, so snippets written without Tailwind port straight into arbitrary variants.
Building a scroll progress bar
A fixed strip at the top of the viewport can show how far the page has scrolled, with the fill following the scroll position. A scroll listener drives it, or animation-timeline: scroll() where browsers support it.
- Fix the track to the top with
fixed top-0 left-0 z-50 h-1 w-full, or h-2 for a heavier strip. - Add a fill inside it with
h-full w-0 bg-blue-600 and an id the script can find. - Write the scroll listener, which divides scroll position by scrollable height and writes the result to the fill width.
- Or skip the listener and use one CSS rule that animates width from 0 to 100% on a scroll timeline.
- Wrap the CSS rule in
@supports (animation-timeline: scroll()) and keep the listener as the fallback.
const bar = document.getElementById("bar");
if (!CSS.supports("animation-timeline: scroll()")) {
window.addEventListener("scroll", () => {
const max = document.documentElement.scrollHeight - window.innerHeight;
bar.style.width = (max > 0 ? (window.scrollY / max) * 100 : 0) + "%";
}, { passive: true });
}
Without the max > 0 guard, a page shorter than the viewport divides by zero and the width turns invalid.
@keyframes grow { from { width: 0; } to { width: 100%; } }
@supports (animation-timeline: scroll()) {
#bar {
animation: grow 1ms linear both;
animation-timeline: scroll(root);
}
}
Tailwind has no core utility for timelines (none in v4.3.3), so a single arbitrary property such as [animation-timeline:scroll(root)] is the utility route.
One quirk decides whether it works. The animation shorthand resets animation-timeline, so the timeline declaration must come after it (MDN). Utility classes cannot guarantee that order, because the generated stylesheet decides it and the class attribute does not, so keep both declarations in one rule.
Support is the real limit. MDN’s compatibility data lists animation-timeline in Chrome and Edge from 115, Safari from 26 and Firefox from 159. Firefox 159 shipped on October 27, 2026, so every major engine has it now, but only in its latest versions.
That makes the feature brand new across browsers, far from Baseline widely available. Visitors on Firefox 158 or older, or Safari before 26, get a bar only through the listener, and older posts that describe Firefox support are referring to a flag, not a default, because stable support only arrived with Firefox 159.
I’d ship both. The CSS rule is a progressive enhancement, and the listener is short enough to keep as the safety net.
A reading-position strip repeats what the scrollbar already reports, so aria-hidden="true" is the usual choice unless the bar shows something the scrollbar does not.
Mount the strip above any sticky navigation header with a higher z-index, or the header covers it.
More patterns for tying motion to scrolling are collected under CSS scroll effects.
Choosing between a div, the native element and a component library
The hand-built div suits designs that need control over the fill. When semantics matter more than pixel-level styling, the native progress element is the better pick, and a library only makes sense if the project already ships one.
| Option | Strength | Cost |
|---|
| Hand-built div | Full control over fill, gradients and labels | ARIA and value sync by hand |
| Native progress element | Built-in role and indeterminate state | Separate selectors per engine |
| daisyUI progress | One class on the progress tag, 8 color modifiers | Needs the daisyUI plugin |
| shadcn/ui Progress | Radix primitive that follows progressbar role rules | React only, plus the 2.86 kB package |
| FlyonUI progress | Ready-made progress component with its own modifier classes | Another plugin on top of Tailwind |
The sources point in two directions. MDN recommends the native element and warns that div markup must recreate every native feature with ARIA, JavaScript and CSS.
Radix goes the other way. Its docs describe parts built to the progressbar role, and its demo slides the indicator with a translateX transform instead of setting width. It also exposes a data-state attribute with loading, complete and indeterminate values, which gives Tailwind classes a hook for each state.
Kits that ship div markup, Flowbite and Material Tailwind among them, sit on the hand-built route, so MDN’s note applies to them as well.
For a single bar, the native element wins. It is one element with a built-in role, and there is nothing to install. A library earns its place only when the project already uses it, because Radix’s docs list the progress package at 2.86 kB (measured on version 1.1.17; npm’s latest is 1.2.1) for a component a few lines of markup replace.
Teams weighing several components at once should compare them side by side before committing.
Before adopting any kit, inspect the rendered markup for a role, an accessible name and a current value. That is the baseline for accessible UI components.
Tailwind Progress Bar FAQ
Can you make a circular progress bar with Tailwind?
Yes, but no core utility does it. daisyUI’s radial progress uses a div with a –value CSS variable and role=”progressbar”, because text cannot be shown inside the native progress tag.
A hand-built version offsets an SVG circle’s stroke by the circumference times the remaining share.
How do you build a multi-step form progress indicator?
Divide the current step by the total step count and bind the result to the fill width. Step 2 of 5 gives 40%.
When users need named stages or backward jumps, a stepper such as daisyUI’s Steps component fits better than a continuous bar.
How do you show file upload progress?
Listen to the progress event on xhr.upload and divide event.loaded by event.total, as MDN’s XMLHttpRequestUpload example does.
Write the result into the state that drives the fill width and the aria-valuenow value.
Does Tailwind v4 change how a progress bar is written?
No. The two-div structure carries over, and only radius names and the CSS variable syntax change.
The v4 upgrade guide renames bare rounded to rounded-sm and rounded-sm to rounded-xs, while rounded-full is untouched. The bracket form w-[–progress] becomes w-(–progress).
Checking a Tailwind Progress Bar Before Release
A bar is ready to ship once the fill renders in a production build and the value reaches a screen reader. The transition should also stay quiet for visitors who request reduced motion.
Test in that order. The production build comes first, because a missing class invalidates every later test. A screen reader comes next, with aria-valuenow read aloud. The reduced-motion setting goes last, since it only changes the transition.
Tailwind v4 targets Safari 16.4, Chrome 111 and Firefox 128 (Tailwind upgrade guide, October 2026), while MDN’s compatibility data lists animation-timeline in Firefox only from 159.
That leaves 31 Firefox releases, 128 through 158, that run v4 without scroll timelines, so a scroll bar keeps its listener.
The listener can go once the project’s browser support list drops Firefox versions before 159 (position verified October 2026).
Flows with named stages call for a stepper component instead of a single bar.