EmpireUI
Get Pro
← Blog8 min read#tailwind#progress bar#animation

Progress Bar in Tailwind: Animated, Striped, Segmented Variants

Build animated, striped, and segmented progress bars with pure Tailwind CSS and React. No plugins needed — just utility classes and a bit of JavaScript.

Code editor showing animated progress bar component in Tailwind CSS

Why Build a Custom Progress Bar Instead of Using the Native One

The HTML <progress> element exists. You know it does. And you've probably also seen what it looks like unstyled — a gray bar from 1998 that varies wildly between browsers. Chrome, Firefox, and Safari all render it differently, and trying to style it with CSS is an exercise in frustration involving pseudo-elements that only work in some browsers.

Tailwind gives you a much cleaner path: a <div> with a nested <div>, controlled widths, and all the animation primitives you need baked into the config. You're not fighting browser defaults. You're building something that actually looks like your app.

Honestly, the native <progress> element is fine if you want zero JavaScript and don't care about appearance. But if you're already using Tailwind, a custom bar takes maybe 20 lines and gives you full visual control. The tradeoff is worth it every time.

Worth noting: you'll want role="progressbar", aria-valuenow, aria-valuemin, and aria-valuemax attributes on your wrapper div so screen readers treat it like a real progress indicator. Accessibility doesn't go away just because you ditched the native element.

The Basic Tailwind Progress Bar

Start with the simplest possible version. A gray track, a colored fill, controlled by a CSS width. This covers 80% of real use cases — file uploads, form completion, page load indicators.

function ProgressBar({ value = 0, max = 100 }) {
  const pct = Math.min(100, Math.max(0, (value / max) * 100));
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      className="w-full h-3 bg-gray-200 rounded-full overflow-hidden"
    >
      <div
        className="h-full bg-blue-500 rounded-full transition-all duration-500 ease-out"
        style={{ width: `${pct}%` }}
      />
    </div>
  );
}

The transition-all duration-500 is doing real work here. Without it, updating value as a prop causes an instant jump, which feels broken to users. With it, you get a smooth slide that communicates progress meaningfully. The overflow-hidden on the container prevents the fill div from visually escaping the rounded corners — a subtle thing that looks wrong without it.

One more thing — the rounded-full on both the container and the fill child matters. If you only put it on the container, you'll see a flat right edge on the fill at values below 100%. Put it on both and they'll round together properly at the filled end while the container clips the other.

For Tailwind v4 (released in early 2025), you can also define your progress track color via CSS custom properties in @layer base if you want it to respond to dark mode automatically. That's cleaner than writing dark:bg-gray-700 everywhere.

Animated Striped Progress Bar

Striped progress bars are the classic "something is happening" indicator. You've seen them in Bootstrap since forever, but building one in Tailwind is a bit more direct — you're writing the CSS animation yourself, which means you actually understand what's going on.

The trick is a repeating linear gradient that creates diagonal stripes, combined with a @keyframes animation that shifts the background position. Tailwind's arbitrary value syntax makes this manageable without leaving the className.

// In your global CSS or tailwind.config.js
// globals.css
@keyframes stripes {
  from { background-position: 0 0; }
  to   { background-position: 40px 0; }
}

.progress-striped {
  background-image: linear-gradient(
    -45deg,
    rgba(255,255,255,0.2) 25%,
    transparent 25%,
    transparent 50%,
    rgba(255,255,255,0.2) 50%,
    rgba(255,255,255,0.2) 75%,
    transparent 75%
  );
  background-size: 40px 40px;
  animation: stripes 1s linear infinite;
}
function StripedProgressBar({ value = 0, max = 100, active = true }) {
  const pct = Math.min(100, Math.max(0, (value / max) * 100));
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      className="w-full h-4 bg-gray-200 rounded-full overflow-hidden"
    >
      <div
        className={`h-full bg-indigo-500 rounded-full transition-all duration-700 ease-out ${
          active ? 'progress-striped' : ''
        }`}
        style={{ width: `${pct}%` }}
      />
    </div>
  );
}

The active prop lets you toggle the animation on and off — useful when a process completes and you want the bar to show a static 100% fill instead of a perpetually-moving stripe. That's a small UX detail that most implementations skip, but users notice it.

Quick aside: the stripe width of 40px is intentional. Go below 20px and the stripes become visual noise. Go above 60px and they stop looking like stripes and start looking like awkward blocks. 40px is the sweet spot for most bar heights between 8px and 20px.

Indeterminate / Pulse Animation (No Value Known)

Sometimes you genuinely don't know how far along something is. API call, file processing, background sync — the operation is in flight but you can't express it as a percentage. This is where the indeterminate style comes in.

Tailwind ships animate-pulse and animate-bounce out of the box, but for progress bars, neither feels right. Pulse makes the whole thing blink. Bounce is even worse. What you want is a sliding shimmer — a fill that slides back and forth across the track, signaling activity without implying completion.

// globals.css
@keyframes indeterminate {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(400%); }
}

.progress-indeterminate {
  animation: indeterminate 1.5s ease-in-out infinite;
  width: 30% !important;
}
function IndeterminateBar({ label = 'Loading...' }) {
  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-busy="true"
      className="w-full h-3 bg-gray-200 rounded-full overflow-hidden"
    >
      <div className="h-full bg-blue-500 rounded-full progress-indeterminate" />
    </div>
  );
}

Note the aria-busy="true" — that's the correct semantic signal for screen readers when the value is genuinely unknown. Skip aria-valuenow entirely in this state; setting it to null or undefined is fine and tells assistive tech not to announce a percentage. See the wcag-accessibility-guide post for more on ARIA patterns like this.

In practice, I swap between this component and the determinate one based on a loading boolean. If the API returns progress data, great — show a real percentage. If it doesn't, fall back to indeterminate. Mixing both in the same interface without that toggle usually ends in a bar stuck at 0% forever.

Segmented Progress Bar (Step Indicator)

Segmented bars — where the bar is visually divided into discrete chunks — show up in multi-step forms, onboarding flows, and quiz interfaces. They communicate "you are on step 3 of 5" in a way that a continuous bar just doesn't.

The implementation is a flex row of individual segments, each independently colored based on the current step. No CSS gradients needed. No magic numbers.

function SegmentedProgressBar({ steps = 5, current = 0 }) {
  return (
    <div className="flex gap-1 w-full" role="progressbar" aria-valuenow={current} aria-valuemin={0} aria-valuemax={steps}>
      {Array.from({ length: steps }).map((_, i) => (
        <div
          key={i}
          className={`h-2 flex-1 rounded-full transition-all duration-300 ${
            i < current
              ? 'bg-blue-500'
              : i === current
              ? 'bg-blue-300'
              : 'bg-gray-200'
          }`}
        />
      ))}
    </div>
  );
}

Three visual states: completed (full blue), active (lighter blue), and pending (gray). You can swap those classes for any color — this pattern works well with neobrutalism styles where you'd use solid black borders on each segment, or with glassmorphism components where you'd add backdrop-blur to the track.

The gap-1 (4px gap in Tailwind's default scale) between segments is a deliberate choice. Go to gap-0.5 and the segments feel cramped. Go to gap-2 and it starts looking like a dotted line. 4px reads cleanly at bar heights from 6px to 16px.

Look, if you're building an onboarding flow, check the onboarding-flow-react post too. The segmented bar pairs well with a stepper component that manages the step state above it.

Multi-Color and Stacked Progress Bars

Disk usage, budget trackers, skill breakdowns — sometimes you need a single bar that shows multiple categories at once. The approach is the same flex trick from the segmented bar, but instead of equal-width segments, each child's width is proportional to its value.

function StackedProgressBar({ segments = [] }) {
  // segments: [{ label: 'Used', value: 60, color: 'bg-blue-500' }, ...]
  const total = segments.reduce((acc, s) => acc + s.value, 0);
  return (
    <div className="flex w-full h-4 rounded-full overflow-hidden" role="group" aria-label="Usage breakdown">
      {segments.map((seg, i) => (
        <div
          key={i}
          title={`${seg.label}: ${seg.value}%`}
          className={`${seg.color} transition-all duration-500 first:rounded-l-full last:rounded-r-full`}
          style={{ width: `${(seg.value / total) * 100}%` }}
        />
      ))}
    </div>
  );
}

// Usage
<StackedProgressBar segments={[
  { label: 'Images', value: 40, color: 'bg-blue-500' },
  { label: 'Videos', value: 25, color: 'bg-purple-500' },
  { label: 'Docs',   value: 15, color: 'bg-green-500' },
  { label: 'Free',   value: 20, color: 'bg-gray-200'  },
]} />

The first:rounded-l-full last:rounded-r-full combo on the children — combined with overflow-hidden on the parent — gives you rounded ends without having to manually check indices. That said, overflow-hidden and rounded-full together on the parent already clips the children, so the child classes are technically redundant. I keep them for the edge case where the parent gets a flat style later.

Honestly, adding a tooltip via title isn't ideal for accessibility, but it's a quick win for mouse users. A proper approach would be a visually-hidden <span> next to each segment, or a legend below the bar linking colors to labels.

This component hits its limits when segment values are very small — a 2% segment at 400px bar width is only 8px wide, which is too thin to read or hover on. Worth adding a minWidth floor around 12px if your data can produce tiny segments.

Putting It Together in a React Hook

All these variants are more useful when the progress value is driven by real logic — simulated loading, actual API progress events, or a timer. Here's a reusable useProgress hook that covers the simulated case, which is the one you'll prototype with most often.

import { useState, useEffect, useRef } from 'react';

function useProgress({
  duration = 3000,
  autoStart = true,
} = {}) {
  const [value, setValue] = useState(0);
  const [done, setDone] = useState(false);
  const rafRef = useRef<number | null>(null);
  const startRef = useRef<number | null>(null);

  const start = () => {
    setValue(0);
    setDone(false);
    startRef.current = performance.now();
    const tick = (now: number) => {
      const elapsed = now - (startRef.current ?? now);
      const pct = Math.min(100, (elapsed / duration) * 100);
      setValue(pct);
      if (pct < 100) {
        rafRef.current = requestAnimationFrame(tick);
      } else {
        setDone(true);
      }
    };
    rafRef.current = requestAnimationFrame(tick);
  };

  useEffect(() => {
    if (autoStart) start();
    return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
  }, []);

  return { value, done, start };
}

Using requestAnimationFrame instead of setInterval means the animation runs in sync with the browser's paint cycle — no jank, no drift. With a 3-second duration you'll get roughly 180 ticks at 60fps, each advancing the bar about 0.55%. Totally smooth.

Pair this hook with any of the bar components above. The done boolean lets you switch from a striped active bar to a solid completed state, or trigger a success animation. It's the kind of detail that makes a loading UI feel polished rather than bolted on.

If you're working with real upload or processing APIs, swap out the rAF loop for a listener on whatever progress event the API exposes, and just call setValue(receivedBytes / totalBytes * 100) directly. The component stays the same — only the data source changes. That separation is what makes this worth extracting into a hook at all.

Want to see this used inside a full dashboard layout? The tailwind-dashboard-layout post has a real-world example with file upload cards, and the skeleton-loader-react post pairs well for the loading states before the progress bar takes over.

FAQ

Can I animate a Tailwind progress bar without writing custom CSS?

For basic transitions, yes — transition-all duration-500 handles smooth width changes entirely in Tailwind utility classes. For striped or indeterminate animations you'll need one or two @keyframes blocks in your global CSS, since Tailwind doesn't generate arbitrary animation keyframes.

How do I make a progress bar accessible in React?

Add role="progressbar", aria-valuenow, aria-valuemin, and aria-valuemax to your container div. For indeterminate bars, set aria-busy="true" and omit aria-valuenow entirely — announcing an unknown percentage to screen readers is worse than announcing nothing.

What's the right height for a progress bar in Tailwind?

Most UI patterns land between h-2 (8px) and h-4 (16px). Use h-2 for subtle secondary indicators, h-3 for standard progress bars, and h-4 or larger when the bar is a primary UI element users need to read at a glance.

Does Tailwind v4 change anything about how progress bars work?

Not fundamentally — the utility classes you need are all still there. Tailwind v4 does make it easier to define custom animations via CSS-first config in @layer utilities, which cleans up the tailwind.config.js approach for custom keyframes. Otherwise, the same patterns apply.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Button Component Variants in Tailwind: Primary, Ghost, Icon, LoadingTailwind vs CSS Modules in 2026: Which One Should You Actually Use?Accordion in React: Radix vs Custom — Animated Height TransitionsFree Stacked Cards Component for React — Cards Stack Animation