Tailwind Progress Bar: 16 Examples, From Striped to Native

The usual Tailwind progress bar is two nested divs: a gray track and a colored fill whose width carries the percentage. Values that change at runtime need an inline style or a CSS variable, because Tailwind never sees a class built from an interpolated string.

Below are 16 Tailwind progress bar examples you can copy, followed by a guide to markup, width binding, accessibility, the native progress element and scroll bars.

By Bogdan Sandu ยท Updated October 29, 2026

Every example is built with Tailwind CSS v4.3 utility classes. To try one quickly, add the Play CDN tags below to your page and paste the example's HTML, CSS and JS. The dark variant line makes dark: classes follow a .dark class instead of the system setting. For production, install Tailwind with Vite or the CLI.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
  @custom-variant dark (&:where(.dark, .dark *));
</style>

Bars, labels, sizes and status colors

The two-div bar in its everyday forms: labels above, beside and inside the track, heights and segmented shapes, status colors backed by words, striped fills and vertical columns. Every track carries role=progressbar with a name and a current value.

Labels above, beside and inside the track

  • role=progressbar
  • aria-labelledby
  • overflow-hidden
  • tabular-nums
  • flex

Each track is a rounded-full div with overflow-hidden and role=progressbar, named by the visible label through aria-labelledby, and the fill takes its width from an inline style. The inside label is a flex child of the fill pushed to the end with justify-end, and the low-value bar turns the track into a flex row so the label sits after a fill that is too narrow to hold it. tabular-nums keeps the digits from shifting as values change.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <h2 class="text-base font-semibold text-slate-900">Release 4.2 rollout</h2>
  <p class="mt-1 text-sm text-slate-600">Five ways to place the value, from a quiet caption to a label inside the fill.</p>
  <div class="mt-6 space-y-6">
    <div>
      <div class="flex items-baseline justify-between gap-4 text-sm">
        <span id="tpb-lab-1" class="font-medium text-slate-900">Onboarding videos</span>
        <span class="text-slate-600 tabular-nums">64%</span>
      </div>
      <div role="progressbar" aria-labelledby="tpb-lab-1" aria-valuenow="64" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-2 overflow-hidden rounded-full bg-slate-100">
        <div class="h-full rounded-full bg-indigo-600" style="width:64%"></div>
      </div>
    </div>
    <div class="flex items-center gap-3">
      <span id="tpb-lab-2" class="w-24 shrink-0 text-sm font-medium text-slate-900">Translations</span>
      <div role="progressbar" aria-labelledby="tpb-lab-2" aria-valuenow="38" aria-valuemin="0" aria-valuemax="100" class="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-slate-100">
        <div class="h-full rounded-full bg-teal-600" style="width:38%"></div>
      </div>
      <span class="w-10 shrink-0 text-right text-sm text-slate-600 tabular-nums">38%</span>
    </div>
    <div>
      <p id="tpb-lab-3" class="text-sm font-medium text-slate-900">Data migration</p>
      <div role="progressbar" aria-labelledby="tpb-lab-3" aria-valuenow="81" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-5 overflow-hidden rounded-full bg-slate-100">
        <div class="flex h-full items-center justify-end rounded-full bg-slate-900 pe-2.5 text-xs font-semibold text-white tabular-nums" style="width:81%">81%</div>
      </div>
    </div>
    <div>
      <p id="tpb-lab-4" class="text-sm font-medium text-slate-900">Image backfill</p>
      <div role="progressbar" aria-labelledby="tpb-lab-4" aria-valuenow="6" aria-valuemin="0" aria-valuemax="100" class="mt-2 flex h-5 items-center overflow-hidden rounded-full bg-slate-100">
        <div class="h-full shrink-0 rounded-full bg-slate-900" style="width:6%"></div>
        <span class="ps-2 text-xs font-semibold text-slate-700 tabular-nums">6%</span>
      </div>
      <p class="mt-1.5 text-xs text-slate-600">Under about 15%, the label moves out of the fill.</p>
    </div>
    <div>
      <p id="tpb-lab-5" class="sr-only">Asset export</p>
      <div role="progressbar" aria-labelledby="tpb-lab-5" aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" aria-valuetext="2.1 GB of 3.4 GB exported" class="h-1.5 overflow-hidden rounded-full bg-slate-100">
        <div class="h-full rounded-full bg-orange-500" style="width:62%"></div>
      </div>
      <p class="mt-2 flex flex-wrap justify-between gap-x-4 text-xs text-slate-600"><span><span class="font-semibold text-slate-900">Asset export:</span> 2.1 GB of 3.4 GB</span><span>About 4 minutes left</span></p>
    </div>
  </div>
</div>

Heights, shapes and a segmented bar

  • h-1 to h-4
  • rounded-*
  • p-0.5
  • grid-cols-10
  • aria-valuetext

Height and radius are the whole size system: h-1 for a hairline, h-2 or h-3 for most UI, and an h-4 track with p-0.5 so the fill floats inside a pill. rounded-none and rounded-sm give a flatter, more technical look. The segmented bar is a grid-cols-10 track with gap-1, where filled cells change color and aria-valuetext says 7 of 10 tasks instead of a percentage.

View code
<div class="w-full max-w-lg rounded-2xl bg-zinc-50 p-6 ring-1 ring-zinc-200 sm:p-8">
  <h2 class="text-base font-semibold text-zinc-900">Heights and shapes</h2>
  <p class="mt-1 text-sm text-zinc-600">Same 58% value, six treatments.</p>
  <ul class="mt-6 space-y-5">
    <li>
      <p class="flex justify-between text-xs text-zinc-600"><span id="tpb-sz-1" class="font-medium text-zinc-900">Hairline</span><code>h-1</code></p>
      <div role="progressbar" aria-labelledby="tpb-sz-1" aria-valuenow="58" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-1 overflow-hidden rounded-full bg-zinc-200">
        <div class="h-full rounded-full bg-amber-600" style="width:58%"></div>
      </div>
    </li>
    <li>
      <p class="flex justify-between text-xs text-zinc-600"><span id="tpb-sz-2" class="font-medium text-zinc-900">Default</span><code>h-2</code></p>
      <div role="progressbar" aria-labelledby="tpb-sz-2" aria-valuenow="58" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-2 overflow-hidden rounded-full bg-zinc-200">
        <div class="h-full rounded-full bg-amber-600" style="width:58%"></div>
      </div>
    </li>
    <li>
      <p class="flex justify-between text-xs text-zinc-600"><span id="tpb-sz-3" class="font-medium text-zinc-900">Soft corners</span><code>h-3 rounded-sm</code></p>
      <div role="progressbar" aria-labelledby="tpb-sz-3" aria-valuenow="58" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-3 overflow-hidden rounded-sm bg-zinc-200">
        <div class="h-full bg-amber-600" style="width:58%"></div>
      </div>
    </li>
    <li>
      <p class="flex justify-between text-xs text-zinc-600"><span id="tpb-sz-4" class="font-medium text-zinc-900">Inset pill</span><code>h-4 p-0.5</code></p>
      <div role="progressbar" aria-labelledby="tpb-sz-4" aria-valuenow="58" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-4 rounded-full bg-zinc-900 p-0.5">
        <div class="h-full rounded-full bg-amber-400" style="width:58%"></div>
      </div>
    </li>
    <li>
      <p class="flex justify-between text-xs text-zinc-600"><span id="tpb-sz-5" class="font-medium text-zinc-900">Square</span><code>h-3 rounded-none</code></p>
      <div role="progressbar" aria-labelledby="tpb-sz-5" aria-valuenow="58" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-3 rounded-none border border-zinc-900 bg-white">
        <div class="h-full bg-zinc-900" style="width:58%"></div>
      </div>
    </li>
    <li>
      <p class="flex justify-between text-xs text-zinc-600"><span id="tpb-sz-6" class="font-medium text-zinc-900">Segmented, 7 of 10 tasks</span><code>grid-cols-10</code></p>
      <div role="progressbar" aria-labelledby="tpb-sz-6" aria-valuenow="7" aria-valuemin="0" aria-valuemax="10" aria-valuetext="7 of 10 tasks" class="mt-2 grid h-2 grid-cols-10 gap-1">
        <span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-amber-600"></span><span class="rounded-full bg-zinc-200"></span><span class="rounded-full bg-zinc-200"></span><span class="rounded-full bg-zinc-200"></span>
      </div>
    </li>
  </ul>
</div>

Status colors that also say it in words

  • bg-emerald-600
  • bg-amber-500
  • bg-red-600
  • aria-valuetext
  • ring-*

Green, amber and red only reinforce the message: each row also has an icon and a status word, so the meaning survives color blindness and grayscale. aria-valuetext folds the status into what a screen reader hears (81%, nearly full), and the row tint comes from 50-level backgrounds with a matching ring-1 so the text colors stay above 4.5:1.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <div class="flex items-center justify-between gap-4">
    <h2 class="text-base font-semibold text-slate-900">Plan usage</h2>
    <span class="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700">Team plan</span>
  </div>
  <p class="mt-1 text-sm text-slate-600">Resets on November 1.</p>
  <ul class="mt-6 space-y-3">
    <li class="rounded-xl bg-emerald-50 p-4 ring-1 ring-emerald-200">
      <div class="flex items-center justify-between gap-3 text-sm">
        <span id="tpb-st-1" class="font-medium text-slate-900">API requests</span>
        <span class="flex items-center gap-1.5 font-semibold text-emerald-800">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>Healthy
        </span>
      </div>
      <div role="progressbar" aria-labelledby="tpb-st-1" aria-valuenow="42" aria-valuemin="0" aria-valuemax="100" aria-valuetext="42%, healthy" class="mt-2.5 h-2 overflow-hidden rounded-full bg-white ring-1 ring-emerald-200">
        <div class="h-full rounded-full bg-emerald-600" style="width:42%"></div>
      </div>
      <p class="mt-2 text-xs text-slate-700 tabular-nums">420,118 of 1,000,000 requests</p>
    </li>
    <li class="rounded-xl bg-amber-50 p-4 ring-1 ring-amber-200">
      <div class="flex items-center justify-between gap-3 text-sm">
        <span id="tpb-st-2" class="font-medium text-slate-900">File storage</span>
        <span class="flex items-center gap-1.5 font-semibold text-amber-800">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M10 7v4m0 3h.01M8.6 3.4 2.3 14.5A1.6 1.6 0 0 0 3.7 17h12.6a1.6 1.6 0 0 0 1.4-2.5L11.4 3.4a1.6 1.6 0 0 0-2.8 0Z"/></svg>Nearly full
        </span>
      </div>
      <div role="progressbar" aria-labelledby="tpb-st-2" aria-valuenow="81" aria-valuemin="0" aria-valuemax="100" aria-valuetext="81%, nearly full" class="mt-2.5 h-2 overflow-hidden rounded-full bg-white ring-1 ring-amber-200">
        <div class="h-full rounded-full bg-amber-500" style="width:81%"></div>
      </div>
      <p class="mt-2 text-xs text-slate-700 tabular-nums">81 GB of 100 GB</p>
    </li>
    <li class="rounded-xl bg-red-50 p-4 ring-1 ring-red-200">
      <div class="flex items-center justify-between gap-3 text-sm">
        <span id="tpb-st-3" class="font-medium text-slate-900">Editor seats</span>
        <span class="flex items-center gap-1.5 font-semibold text-red-800">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM4.5 15.5l11-11"/></svg>Limit reached
        </span>
      </div>
      <div role="progressbar" aria-labelledby="tpb-st-3" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" aria-valuetext="100%, limit reached" class="mt-2.5 h-2 overflow-hidden rounded-full bg-white ring-1 ring-red-200">
        <div class="h-full rounded-full bg-red-600" style="width:100%"></div>
      </div>
      <div class="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-700">
        <span class="tabular-nums">10 of 10 seats</span>
        <a href="#" class="rounded font-semibold text-red-800 underline underline-offset-2 hover:text-red-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-700">Add seats</a>
      </div>
    </li>
  </ul>
</div>

Striped and animated stripes with a pause button

  • @utility
  • @keyframes
  • motion-safe:
  • group-data-[paused]:
  • [animation-play-state:paused]

Tailwind has no stripe utility, so @utility tpb-stripes lays a hard-stop repeating-linear-gradient of translucent white over whatever bg-* color the fill has, and tpb-stripes-run slides it by exactly one stripe period so the loop has no seam. The motion only applies under motion-safe:, and because moving content longer than five seconds needs a way to stop it, the button sets data-paused on the wrapper and group-data-[paused]:[animation-play-state:paused] freezes every fill.

View code
<div id="tpb-stripe-box" class="group w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <div class="flex flex-wrap items-center justify-between gap-3">
    <h2 class="text-base font-semibold text-slate-900">Nightly jobs</h2>
    <button type="button" id="tpb-stripe-pause" aria-pressed="false" class="inline-flex min-h-11 items-center gap-2 rounded-lg px-3 text-sm font-semibold text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600">
      <svg class="size-4 group-data-[paused]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4h2.5v12H6zM11.5 4H14v12h-2.5z"/></svg>
      <svg class="hidden size-4 group-data-[paused]:block" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path d="M6 4l10 6-10 6z"/></svg>
      Pause animation
    </button>
  </div>
  <div class="mt-6 space-y-6">
    <div>
      <div class="flex justify-between gap-4 text-sm"><span id="tpb-sp-1" class="font-medium text-slate-900">Database backup</span><span class="text-slate-600 tabular-nums">68%</span></div>
      <div role="progressbar" aria-labelledby="tpb-sp-1" aria-valuenow="68" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-3 overflow-hidden rounded-full bg-sky-100">
        <div class="tpb-stripes h-full rounded-full bg-sky-600 motion-safe:tpb-stripes-run group-data-[paused]:[animation-play-state:paused]" style="width:68%"></div>
      </div>
    </div>
    <div>
      <div class="flex justify-between gap-4 text-sm"><span id="tpb-sp-2" class="font-medium text-slate-900">Search reindex</span><span class="text-slate-600 tabular-nums">35%</span></div>
      <div role="progressbar" aria-labelledby="tpb-sp-2" aria-valuenow="35" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-3 overflow-hidden rounded-full bg-fuchsia-100">
        <div class="tpb-stripes h-full rounded-full bg-fuchsia-700 motion-safe:tpb-stripes-run group-data-[paused]:[animation-play-state:paused]" style="width:35%"></div>
      </div>
    </div>
    <div>
      <div class="flex justify-between gap-4 text-sm"><span id="tpb-sp-3" class="font-medium text-slate-900">Invoice export, queued</span><span class="text-slate-600 tabular-nums">20%</span></div>
      <div role="progressbar" aria-labelledby="tpb-sp-3" aria-valuenow="20" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-3 overflow-hidden rounded-sm bg-slate-100">
        <div class="tpb-stripes h-full rounded-sm bg-slate-500" style="width:20%"></div>
      </div>
      <p class="mt-1.5 text-xs text-slate-600">Static stripes for a waiting job, no animation needed.</p>
    </div>
    <div class="rounded-xl bg-slate-900 p-4">
      <div class="flex justify-between gap-4 text-sm"><span id="tpb-sp-4" class="font-medium text-white">Video transcode</span><span class="text-slate-300 tabular-nums">54%</span></div>
      <div role="progressbar" aria-labelledby="tpb-sp-4" aria-valuenow="54" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-4 overflow-hidden rounded-full bg-slate-700">
        <div class="tpb-stripes h-full rounded-full bg-lime-500 motion-safe:tpb-stripes-run group-data-[paused]:[animation-play-state:paused]" style="width:54%"></div>
      </div>
    </div>
  </div>
</div>

Vertical bars for a weekly step goal

  • flex-col
  • justify-end
  • grid-cols-7
  • aria-valuetext
  • sr-only

A vertical bar is the same two divs turned on end: the track is a flex-col with justify-end, so the fill grows up from the bottom, and the value goes into an inline height instead of width. Each column is its own progressbar with aria-valuemax=10000 and an aria-valuetext in steps, and days over the goal clamp at 100% while a check icon and the words goal met carry the extra meaning.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-orange-100 sm:p-8">
  <div class="flex flex-wrap items-end justify-between gap-2">
    <div>
      <h2 class="text-base font-semibold text-stone-900">Steps this week</h2>
      <p class="mt-1 text-sm text-stone-600">Daily goal 10,000 steps</p>
    </div>
    <p class="text-sm text-stone-700"><span class="text-2xl font-bold text-stone-900 tabular-nums">3</span> of 7 goals met</p>
  </div>
  <ul class="mt-6 grid grid-cols-7 gap-2 sm:gap-4">
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">8.2k</span>
      <div role="progressbar" aria-label="Monday" aria-valuenow="8240" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="8,240 of 10,000 steps" class="flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100">
        <div class="rounded-full bg-orange-500" style="height:82%"></div>
      </div>
      <span class="text-xs text-stone-600" aria-hidden="true">Mon</span>
    </li>
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">12.4k</span>
      <div role="progressbar" aria-label="Tuesday" aria-valuenow="10000" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="12,400 steps, goal met" class="relative flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100">
        <div class="flex h-full items-start justify-center rounded-full bg-orange-700 pt-2 text-white">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </div>
      </div>
      <span class="text-xs text-stone-600" aria-hidden="true">Tue</span>
    </li>
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">4.9k</span>
      <div role="progressbar" aria-label="Wednesday" aria-valuenow="4900" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="4,900 of 10,000 steps" class="flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100">
        <div class="rounded-full bg-orange-500" style="height:49%"></div>
      </div>
      <span class="text-xs text-stone-600" aria-hidden="true">Wed</span>
    </li>
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">10.1k</span>
      <div role="progressbar" aria-label="Thursday" aria-valuenow="10000" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="10,100 steps, goal met" class="flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100">
        <div class="flex h-full items-start justify-center rounded-full bg-orange-700 pt-2 text-white">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </div>
      </div>
      <span class="text-xs text-stone-600" aria-hidden="true">Thu</span>
    </li>
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">6.7k</span>
      <div role="progressbar" aria-label="Friday" aria-valuenow="6700" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="6,700 of 10,000 steps" class="flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100">
        <div class="rounded-full bg-orange-500" style="height:67%"></div>
      </div>
      <span class="text-xs text-stone-600" aria-hidden="true">Fri</span>
    </li>
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">15.8k</span>
      <div role="progressbar" aria-label="Saturday" aria-valuenow="10000" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="15,800 steps, goal met" class="flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100">
        <div class="flex h-full items-start justify-center rounded-full bg-orange-700 pt-2 text-white">
          <svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </div>
      </div>
      <span class="text-xs text-stone-600" aria-hidden="true">Sat</span>
    </li>
    <li class="flex flex-col items-center gap-2">
      <span class="text-[11px] font-semibold text-stone-700 tabular-nums">2.3k</span>
      <div role="progressbar" aria-label="Sunday, today" aria-valuenow="2300" aria-valuemin="0" aria-valuemax="10000" aria-valuetext="2,300 of 10,000 steps so far" class="flex h-36 w-full max-w-9 flex-col justify-end overflow-hidden rounded-full bg-orange-100 ring-2 ring-stone-900 ring-offset-2">
        <div class="rounded-full bg-orange-500" style="height:23%"></div>
      </div>
      <span class="text-xs font-semibold text-stone-900" aria-hidden="true">Today</span>
    </li>
  </ul>
  <p class="mt-5 flex items-center gap-2 text-xs text-stone-600"><span class="inline-block size-2.5 rounded-full bg-orange-700" aria-hidden="true"></span>Dark column with a check: goal met</p>
</div>

Native progress and meter elements

The built-in elements styled through arbitrary variants such as [&::-webkit-progress-value]: and [&::-moz-progress-bar]:. No ARIA to add, an indeterminate state for free, and meter for measurements that are not progress.

The progress element styled with arbitrary variants

  • [&::-webkit-progress-bar]:
  • [&::-webkit-progress-value]:
  • [&::-moz-progress-bar]:
  • appearance-none
  • [&:indeterminate]:

appearance-none switches off the native look, then [&::-webkit-progress-bar]: styles the track and [&::-webkit-progress-value]: the fill in Chrome, Edge and Safari, while Firefox draws the track on the element itself and the fill through [&::-moz-progress-bar]:, so every color is written twice. The label element names each bar through for, and the last one has no value attribute, which makes it indeterminate: :indeterminate swaps in the tpb-sweep stripes because WebKit and Blink draw nothing inside an unstyled indeterminate bar once appearance is none.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <h2 class="text-base font-semibold text-slate-900">Import status</h2>
  <p class="mt-1 text-sm text-slate-600">Real progress elements, no ARIA added.</p>
  <div class="mt-6 space-y-6">
    <div>
      <label for="tpb-nat-1" class="flex justify-between gap-4 text-sm"><span class="font-medium text-slate-900">Contacts</span><span class="text-slate-600 tabular-nums">45%</span></label>
      <progress id="tpb-nat-1" value="45" max="100" class="mt-2 block h-2.5 w-full appearance-none overflow-hidden rounded-full border-0 bg-slate-200 [&::-webkit-progress-bar]:bg-slate-200 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-indigo-600 [&::-moz-progress-bar]:rounded-full [&::-moz-progress-bar]:bg-indigo-600">45%</progress>
    </div>
    <div>
      <label for="tpb-nat-2" class="flex justify-between gap-4 text-sm"><span class="font-medium text-slate-900">Deals</span><span class="text-slate-600 tabular-nums">5 of 8 pipelines</span></label>
      <progress id="tpb-nat-2" value="5" max="8" class="mt-2 block h-4 w-full appearance-none overflow-hidden rounded-md border-0 bg-teal-50 ring-1 ring-teal-200 [&::-webkit-progress-bar]:bg-teal-50 [&::-webkit-progress-value]:bg-teal-600 [&::-moz-progress-bar]:bg-teal-600">5 of 8</progress>
      <p class="mt-1.5 text-xs text-slate-600">max="8" counts in pipelines, not percent.</p>
    </div>
    <div>
      <label for="tpb-nat-3" class="flex justify-between gap-4 text-sm"><span class="font-medium text-slate-900">Attachments</span><span class="text-slate-600 tabular-nums">92%</span></label>
      <progress id="tpb-nat-3" value="0.92" class="mt-2 block h-1.5 w-full appearance-none overflow-hidden rounded-full border-0 bg-rose-100 [&::-webkit-progress-bar]:bg-rose-100 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-rose-600 [&::-moz-progress-bar]:rounded-full [&::-moz-progress-bar]:bg-rose-600">92%</progress>
      <p class="mt-1.5 text-xs text-slate-600">No max, so value runs from 0 to 1.</p>
    </div>
    <div>
      <label for="tpb-nat-4" class="flex justify-between gap-4 text-sm"><span class="font-medium text-slate-900">Notes</span><span class="text-slate-600">Counting records</span></label>
      <progress id="tpb-nat-4" max="100" class="mt-2 block h-2.5 w-full appearance-none overflow-hidden rounded-full border-0 bg-slate-200 [&::-webkit-progress-bar]:bg-slate-200 [&:indeterminate]:tpb-sweep">Counting records</progress>
      <p class="mt-1.5 text-xs text-slate-600">No value attribute, so <code>:indeterminate</code> matches.</p>
    </div>
  </div>
</div>

Quiz progress with a native bar and max

  • <progress max>
  • motion-safe:[&::-webkit-progress-value]:transition-[width]
  • aria-live
  • disabled:
  • focus-visible:

The bar is a progress element with max=5, so the script only sets value to the number of answered questions and never converts to percent. Stacked variants such as motion-safe:[&::-webkit-progress-value]:transition-[width] smooth the fill in Chrome and Safari, and a polite live region reads the feedback after each answer. The answer buttons lock with disabled: styles until Next moves on.

View code
<div class="w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-violet-100 sm:p-8">
  <div class="flex items-center justify-between gap-4 text-sm">
    <label for="tpb-quiz-bar" id="tpb-quiz-count" class="font-semibold text-violet-900">0 of 5 answered</label>
    <span class="text-slate-600">HTML warm-up</span>
  </div>
  <progress id="tpb-quiz-bar" value="0" max="5" class="mt-3 block h-2 w-full appearance-none overflow-hidden rounded-full border-0 bg-violet-100 [&::-webkit-progress-bar]:bg-violet-100 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-violet-700 motion-safe:[&::-webkit-progress-value]:transition-[width] motion-safe:[&::-webkit-progress-value]:duration-500 [&::-moz-progress-bar]:rounded-full [&::-moz-progress-bar]:bg-violet-700">0 of 5</progress>
  <h2 id="tpb-quiz-q" class="mt-6 text-lg font-semibold text-slate-900">Which element has a built-in progressbar role?</h2>
  <div id="tpb-quiz-answers" class="mt-4 grid gap-2"></div>
  <p id="tpb-quiz-feedback" class="mt-4 min-h-5 text-sm font-medium text-slate-700" aria-live="polite"></p>
  <button type="button" id="tpb-quiz-next" disabled class="mt-4 inline-flex min-h-11 w-full items-center justify-center rounded-lg bg-violet-700 px-4 text-sm font-semibold text-white hover:bg-violet-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-700 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-500 sm:w-auto">Next question</button>
</div>

Meter gauges with low, high and optimum

  • <meter>
  • [&::-webkit-meter-bar]:
  • [&::-webkit-meter-optimum-value]:
  • [&::-moz-meter-bar]:
  • [&:-moz-meter-optimum::-moz-meter-bar]:

meter is for a measurement inside a known range, not for a task that finishes, and its low, high and optimum attributes make the browser pick the good, average or poor color on its own. Blink and WebKit expose that choice as ::-webkit-meter-optimum-value, ::-webkit-meter-suboptimum-value and ::-webkit-meter-even-less-good-value, while Firefox keeps one ::-moz-meter-bar and flags the region on the element with :-moz-meter-optimum and friends, so each color gets one arbitrary variant per engine. The status word next to each gauge says the same thing in text.

View code
<div class="w-full max-w-lg rounded-2xl bg-slate-900 p-6 text-slate-100 ring-1 ring-white/10 sm:p-8">
  <h2 class="text-base font-semibold text-white">Field unit 7 health</h2>
  <p class="mt-1 text-sm text-slate-400">The color comes from the meter, not from a script.</p>
  <ul class="mt-6 space-y-6">
    <li>
      <div class="flex items-baseline justify-between gap-4 text-sm">
        <label for="tpb-m-1" class="font-medium text-white">Battery</label>
        <span class="font-semibold text-red-300">18%, charge soon</span>
      </div>
      <meter id="tpb-m-1" min="0" max="100" low="20" high="80" optimum="100" value="18" class="mt-2 block h-3 w-full appearance-none rounded-full bg-slate-700 [&::-webkit-meter-bar]:h-3 [&::-webkit-meter-bar]:rounded-full [&::-webkit-meter-bar]:border-0 [&::-webkit-meter-bar]:bg-slate-700 [&::-webkit-meter-optimum-value]:rounded-full [&::-webkit-meter-optimum-value]:bg-emerald-400 [&::-webkit-meter-suboptimum-value]:rounded-full [&::-webkit-meter-suboptimum-value]:bg-amber-400 [&::-webkit-meter-even-less-good-value]:rounded-full [&::-webkit-meter-even-less-good-value]:bg-red-400 [&::-moz-meter-bar]:rounded-full [&:-moz-meter-optimum::-moz-meter-bar]:bg-emerald-400 [&:-moz-meter-sub-optimum::-moz-meter-bar]:bg-amber-400 [&:-moz-meter-sub-sub-optimum::-moz-meter-bar]:bg-red-400">18%</meter>
      <p class="mt-1.5 text-xs text-slate-400"><code>low="20" high="80" optimum="100"</code></p>
    </li>
    <li>
      <div class="flex items-baseline justify-between gap-4 text-sm">
        <label for="tpb-m-2" class="font-medium text-white">Disk usage</label>
        <span class="font-semibold text-amber-300">72%, getting full</span>
      </div>
      <meter id="tpb-m-2" min="0" max="100" low="60" high="85" optimum="0" value="72" class="mt-2 block h-3 w-full appearance-none rounded-full bg-slate-700 [&::-webkit-meter-bar]:h-3 [&::-webkit-meter-bar]:rounded-full [&::-webkit-meter-bar]:border-0 [&::-webkit-meter-bar]:bg-slate-700 [&::-webkit-meter-optimum-value]:rounded-full [&::-webkit-meter-optimum-value]:bg-emerald-400 [&::-webkit-meter-suboptimum-value]:rounded-full [&::-webkit-meter-suboptimum-value]:bg-amber-400 [&::-webkit-meter-even-less-good-value]:rounded-full [&::-webkit-meter-even-less-good-value]:bg-red-400 [&::-moz-meter-bar]:rounded-full [&:-moz-meter-optimum::-moz-meter-bar]:bg-emerald-400 [&:-moz-meter-sub-optimum::-moz-meter-bar]:bg-amber-400 [&:-moz-meter-sub-sub-optimum::-moz-meter-bar]:bg-red-400">72%</meter>
      <p class="mt-1.5 text-xs text-slate-400"><code>low="60" high="85" optimum="0"</code></p>
    </li>
    <li>
      <div class="flex items-baseline justify-between gap-4 text-sm">
        <label for="tpb-m-3" class="font-medium text-white">CPU temperature</label>
        <span class="font-semibold text-emerald-300">54 ยฐC, normal</span>
      </div>
      <meter id="tpb-m-3" min="20" max="100" low="60" high="80" optimum="40" value="54" class="mt-2 block h-3 w-full appearance-none rounded-full bg-slate-700 [&::-webkit-meter-bar]:h-3 [&::-webkit-meter-bar]:rounded-full [&::-webkit-meter-bar]:border-0 [&::-webkit-meter-bar]:bg-slate-700 [&::-webkit-meter-optimum-value]:rounded-full [&::-webkit-meter-optimum-value]:bg-emerald-400 [&::-webkit-meter-suboptimum-value]:rounded-full [&::-webkit-meter-suboptimum-value]:bg-amber-400 [&::-webkit-meter-even-less-good-value]:rounded-full [&::-webkit-meter-even-less-good-value]:bg-red-400 [&::-moz-meter-bar]:rounded-full [&:-moz-meter-optimum::-moz-meter-bar]:bg-emerald-400 [&:-moz-meter-sub-optimum::-moz-meter-bar]:bg-amber-400 [&:-moz-meter-sub-sub-optimum::-moz-meter-bar]:bg-red-400">54 ยฐC</meter>
      <p class="mt-1.5 text-xs text-slate-400"><code>min="20" low="60" high="80" optimum="40"</code></p>
    </li>
  </ul>
</div>

Progress bars inside real interfaces

Where bars earn their place: a stacked storage meter with a legend, a checkout stepper, a fundraising card, a course dashboard with dark mode and a set of skill bars that grow in with the starting: variant.

Stacked storage bar with a legend

  • flex
  • gap-0.5
  • aria-valuetext
  • grid-cols-2
  • sm:grid-cols-4

The segments are flex children of one track, each with an inline width as a share of the total, and gap-0.5 lets the track color show between them as a divider. The track is a single progressbar for total usage with aria-valuetext in gigabytes, since its children are presentational, and the breakdown lives in the legend where every swatch is backed by a name and a size.

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
    <h2 id="tpb-sto-title" class="text-base font-semibold text-slate-900">Storage</h2>
    <p class="text-sm text-slate-600"><span class="font-semibold text-slate-900 tabular-nums">182.4 GB</span> of 256 GB used</p>
  </div>
  <div role="progressbar" aria-labelledby="tpb-sto-title" aria-valuenow="182.4" aria-valuemin="0" aria-valuemax="256" aria-valuetext="182.4 GB of 256 GB used" class="mt-4 flex h-4 gap-0.5 overflow-hidden rounded-md bg-slate-100">
    <div class="h-full rounded-s-md bg-sky-600" style="width:29%"></div>
    <div class="h-full bg-violet-600" style="width:19.1%"></div>
    <div class="h-full bg-amber-500" style="width:8.4%"></div>
    <div class="h-full bg-slate-500" style="width:14.7%"></div>
  </div>
  <ul class="mt-5 grid grid-cols-2 gap-x-4 gap-y-3 text-sm sm:grid-cols-5">
    <li class="flex items-start gap-2"><span class="mt-1 size-3 shrink-0 rounded-sm bg-sky-600" aria-hidden="true"></span><span><span class="block font-medium text-slate-900">Photos</span><span class="text-slate-600 tabular-nums">74.2 GB</span></span></li>
    <li class="flex items-start gap-2"><span class="mt-1 size-3 shrink-0 rounded-sm bg-violet-600" aria-hidden="true"></span><span><span class="block font-medium text-slate-900">Apps</span><span class="text-slate-600 tabular-nums">48.9 GB</span></span></li>
    <li class="flex items-start gap-2"><span class="mt-1 size-3 shrink-0 rounded-sm bg-amber-500" aria-hidden="true"></span><span><span class="block font-medium text-slate-900">Messages</span><span class="text-slate-600 tabular-nums">21.6 GB</span></span></li>
    <li class="flex items-start gap-2"><span class="mt-1 size-3 shrink-0 rounded-sm bg-slate-500" aria-hidden="true"></span><span><span class="block font-medium text-slate-900">System</span><span class="text-slate-600 tabular-nums">37.7 GB</span></span></li>
    <li class="flex items-start gap-2"><span class="mt-1 size-3 shrink-0 rounded-sm bg-slate-100 ring-1 ring-slate-300" aria-hidden="true"></span><span><span class="block font-medium text-slate-900">Free</span><span class="text-slate-600 tabular-nums">73.6 GB</span></span></li>
  </ul>
  <div class="mt-6 flex flex-col gap-3 rounded-xl bg-sky-50 p-4 sm:flex-row sm:items-center sm:justify-between">
    <p class="text-sm text-sky-900">Photos is the biggest category. Offloading originals frees about 51 GB.</p>
    <a href="#" class="inline-flex min-h-11 shrink-0 items-center justify-center rounded-lg bg-sky-700 px-4 text-sm font-semibold text-white hover:bg-sky-800 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-700">Review photos</a>
  </div>
</div>

Checkout steps with a connecting bar

  • group-data-[state=done]:
  • group-data-[state=current]:
  • aria-current=step
  • motion-safe:transition-[width]
  • grid-cols-4

Each step is an li with data-state set to done, current or upcoming, and group-data-[state=*]: variants restyle the circle, the number and the label from that one attribute, including swapping the number for a check icon. The current step carries aria-current=step, and an sr-only suffix says completed for screen readers. The thin line behind the circles is a decorative track whose fill width the script sets, smoothed by motion-safe:transition-[width].

View code
<div class="w-full max-w-xl rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <nav aria-label="Checkout progress">
    <ol class="relative grid grid-cols-4">
      <li class="pointer-events-none absolute inset-x-[12.5%] top-4 h-0.5 -translate-y-1/2 bg-slate-200" aria-hidden="true"><span id="tpb-steps-fill" class="block h-full bg-violet-600 motion-safe:transition-[width] motion-safe:duration-500" style="width:33.333%"></span></li>
      <li class="tpb-step group relative flex flex-col items-center gap-2 text-center" data-state="done">
        <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 bg-white text-sm font-semibold text-slate-500 group-data-[state=current]:border-violet-600 group-data-[state=current]:text-violet-700 group-data-[state=done]:border-violet-600 group-data-[state=done]:bg-violet-600 group-data-[state=done]:text-white" aria-hidden="true">
          <span class="group-data-[state=done]:hidden">1</span>
          <svg class="hidden size-4 group-data-[state=done]:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </span>
        <span class="text-xs font-medium text-slate-600 group-data-[state=current]:font-semibold group-data-[state=current]:text-slate-900 sm:text-sm">Cart<span class="tpb-step-sr sr-only">, completed</span></span>
      </li>
      <li class="tpb-step group relative flex flex-col items-center gap-2 text-center" data-state="current" aria-current="step">
        <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 bg-white text-sm font-semibold text-slate-500 group-data-[state=current]:border-violet-600 group-data-[state=current]:text-violet-700 group-data-[state=done]:border-violet-600 group-data-[state=done]:bg-violet-600 group-data-[state=done]:text-white" aria-hidden="true">
          <span class="group-data-[state=done]:hidden">2</span>
          <svg class="hidden size-4 group-data-[state=done]:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </span>
        <span class="text-xs font-medium text-slate-600 group-data-[state=current]:font-semibold group-data-[state=current]:text-slate-900 sm:text-sm">Shipping<span class="tpb-step-sr sr-only"></span></span>
      </li>
      <li class="tpb-step group relative flex flex-col items-center gap-2 text-center" data-state="upcoming">
        <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 bg-white text-sm font-semibold text-slate-500 group-data-[state=current]:border-violet-600 group-data-[state=current]:text-violet-700 group-data-[state=done]:border-violet-600 group-data-[state=done]:bg-violet-600 group-data-[state=done]:text-white" aria-hidden="true">
          <span class="group-data-[state=done]:hidden">3</span>
          <svg class="hidden size-4 group-data-[state=done]:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </span>
        <span class="text-xs font-medium text-slate-600 group-data-[state=current]:font-semibold group-data-[state=current]:text-slate-900 sm:text-sm">Payment<span class="tpb-step-sr sr-only"></span></span>
      </li>
      <li class="tpb-step group relative flex flex-col items-center gap-2 text-center" data-state="upcoming">
        <span class="grid size-8 place-items-center rounded-full border-2 border-slate-300 bg-white text-sm font-semibold text-slate-500 group-data-[state=current]:border-violet-600 group-data-[state=current]:text-violet-700 group-data-[state=done]:border-violet-600 group-data-[state=done]:bg-violet-600 group-data-[state=done]:text-white" aria-hidden="true">
          <span class="group-data-[state=done]:hidden">4</span>
          <svg class="hidden size-4 group-data-[state=done]:block" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg>
        </span>
        <span class="text-xs font-medium text-slate-600 group-data-[state=current]:font-semibold group-data-[state=current]:text-slate-900 sm:text-sm">Review<span class="tpb-step-sr sr-only"></span></span>
      </li>
    </ol>
  </nav>
  <div class="mt-8 rounded-xl bg-slate-50 p-5 ring-1 ring-slate-200">
    <p id="tpb-steps-count" class="text-xs font-semibold tracking-wide text-violet-700 uppercase">Step 2 of 4</p>
    <h2 id="tpb-steps-title" tabindex="-1" class="mt-1 text-lg font-semibold text-slate-900 focus:outline-none">Shipping</h2>
    <p id="tpb-steps-text" class="mt-1 text-sm text-slate-600">Choose standard delivery (3 to 5 days) or express (next day).</p>
  </div>
  <div class="mt-6 flex justify-between gap-3">
    <button type="button" id="tpb-steps-back" class="inline-flex min-h-11 items-center rounded-lg px-4 text-sm font-semibold text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 disabled:cursor-not-allowed disabled:opacity-50">Back</button>
    <button type="button" id="tpb-steps-next" class="inline-flex min-h-11 items-center rounded-lg bg-violet-600 px-5 text-sm font-semibold text-white hover:bg-violet-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-600 disabled:cursor-not-allowed disabled:opacity-50">Continue</button>
  </div>
</div>

Fundraising goal card with pledge buttons

  • motion-safe:transition-[width,background-color]
  • aria-valuetext
  • aria-live
  • object-cover
  • data-[done]:

The bar is a progressbar whose aria-valuemax is the dollar goal, so aria-valuenow holds real money and aria-valuetext reads it back with the percentage. Each pledge updates width, value and the stats in one function, and motion-safe:transition-[width,background-color] eases the fill while a data-[done]: variant on the card turns it emerald when the goal is reached. A polite live region thanks the donor with the new total.

View code
<article id="tpb-goal" class="group w-full max-w-sm overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-stone-200">
  <img src="forest-waterfall.webp" width="800" height="600" alt="Waterfall dropping into a mossy green canyon surrounded by fir forest" class="h-44 w-full object-cover">
  <div class="p-6">
    <p class="text-xs font-medium text-emerald-800">Cascade Trails Trust</p>
    <h2 id="tpb-goal-title" class="mt-1 text-lg font-semibold text-stone-900">Rebuild the Alder Falls footbridge</h2>
    <p class="mt-4 text-sm text-stone-700"><span id="tpb-goal-raised" class="text-2xl font-bold text-stone-900 tabular-nums">$18,420</span> raised of $25,000</p>
    <div id="tpb-goal-bar" role="progressbar" aria-labelledby="tpb-goal-title" aria-valuenow="18420" aria-valuemin="0" aria-valuemax="25000" aria-valuetext="$18,420 of $25,000, 74%" class="mt-3 h-3 overflow-hidden rounded-full bg-stone-100">
      <div id="tpb-goal-fill" class="h-full rounded-full bg-orange-600 group-data-[done]:bg-emerald-600 motion-safe:transition-[width,background-color] motion-safe:duration-700 motion-safe:ease-out" style="width:73.68%"></div>
    </div>
    <dl class="mt-4 grid grid-cols-3 gap-2 text-center">
      <div class="rounded-lg bg-stone-50 py-2"><dt class="text-xs text-stone-600">Funded</dt><dd id="tpb-goal-pct" class="font-semibold text-stone-900 tabular-nums">74%</dd></div>
      <div class="rounded-lg bg-stone-50 py-2"><dt class="text-xs text-stone-600">Backers</dt><dd id="tpb-goal-backers" class="font-semibold text-stone-900 tabular-nums">312</dd></div>
      <div class="rounded-lg bg-stone-50 py-2"><dt class="text-xs text-stone-600">Days left</dt><dd class="font-semibold text-stone-900 tabular-nums">9</dd></div>
    </dl>
    <fieldset class="mt-5">
      <legend class="text-sm font-medium text-stone-900">Pledge an amount</legend>
      <div class="mt-2 grid grid-cols-3 gap-2">
        <button type="button" class="tpb-pledge min-h-11 rounded-lg text-sm font-semibold text-stone-800 ring-1 ring-stone-300 hover:bg-orange-50 hover:ring-orange-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600" data-amount="25">$25</button>
        <button type="button" class="tpb-pledge min-h-11 rounded-lg text-sm font-semibold text-stone-800 ring-1 ring-stone-300 hover:bg-orange-50 hover:ring-orange-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600" data-amount="250">$250</button>
        <button type="button" class="tpb-pledge min-h-11 rounded-lg text-sm font-semibold text-stone-800 ring-1 ring-stone-300 hover:bg-orange-50 hover:ring-orange-400 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-orange-600" data-amount="2500">$2,500</button>
      </div>
    </fieldset>
    <p id="tpb-goal-live" class="mt-3 min-h-5 text-sm text-stone-700 group-data-[done]:font-semibold group-data-[done]:text-emerald-800" aria-live="polite"></p>
  </div>
</article>

Course progress list with a dark mode toggle

  • dark:
  • aria-pressed
  • divide-y
  • aria-valuetext
  • focus-visible:

Every color in the card has a dark: partner, and the toggle adds or removes the dark class on the card itself, the same @custom-variant setup a site-wide switch uses. Module bars are small progressbars with aria-valuetext in lessons (3 of 5 lessons), the finished module adds a check and the word Completed, and the locked one uses a dashed empty track plus the word Locked rather than a gray that only implies it.

View code
<div id="tpb-course" class="w-full max-w-xl">
  <div class="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10 sm:p-8">
    <div class="flex items-start justify-between gap-4">
      <div>
        <h2 id="tpb-course-title" class="text-base font-semibold text-slate-900 dark:text-white">Accessible forms, from labels to errors</h2>
        <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">7 of 14 lessons, about 1 h 20 min left</p>
      </div>
      <button type="button" id="tpb-course-theme" aria-pressed="false" class="inline-flex size-11 shrink-0 items-center justify-center rounded-lg text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600 dark:text-slate-200 dark:ring-white/20 dark:hover:bg-white/5">
        <span class="sr-only">Dark mode</span>
        <svg class="size-5 dark:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5Z"/></svg>
        <svg class="hidden size-5 dark:block" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path stroke-linecap="round" d="M12 2v2m0 16v2M2 12h2m16 0h2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
      </button>
    </div>
    <div role="progressbar" aria-labelledby="tpb-course-title" aria-valuenow="50" aria-valuemin="0" aria-valuemax="100" aria-valuetext="50%, 7 of 14 lessons" class="mt-4 h-2.5 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
      <div class="h-full rounded-full bg-teal-600 dark:bg-teal-400" style="width:50%"></div>
    </div>
    <ul class="mt-6 divide-y divide-slate-200 dark:divide-white/10">
      <li class="flex items-center gap-4 py-4">
        <span class="grid size-9 shrink-0 place-items-center rounded-full bg-teal-600 text-white dark:bg-teal-400 dark:text-slate-900" aria-hidden="true"><svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m5 10.5 3 3 7-7"/></svg></span>
        <div class="min-w-0 flex-1">
          <div class="flex flex-wrap items-baseline justify-between gap-x-3">
            <p id="tpb-mod-1" class="font-medium text-slate-900 dark:text-white">Labels and instructions</p>
            <p class="text-xs font-semibold text-teal-700 dark:text-teal-300">Completed</p>
          </div>
          <div role="progressbar" aria-labelledby="tpb-mod-1" aria-valuenow="4" aria-valuemin="0" aria-valuemax="4" aria-valuetext="4 of 4 lessons" class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"><div class="h-full rounded-full bg-teal-600 dark:bg-teal-400" style="width:100%"></div></div>
        </div>
      </li>
      <li class="flex items-center gap-4 py-4">
        <span class="grid size-9 shrink-0 place-items-center rounded-full bg-teal-50 text-sm font-semibold text-teal-800 ring-2 ring-teal-600 dark:bg-teal-400/10 dark:text-teal-200 dark:ring-teal-400" aria-hidden="true">2</span>
        <div class="min-w-0 flex-1">
          <div class="flex flex-wrap items-baseline justify-between gap-x-3">
            <p id="tpb-mod-2" class="font-medium text-slate-900 dark:text-white">Validation and error messages</p>
            <p class="text-xs text-slate-600 dark:text-slate-400">3 of 5 lessons</p>
          </div>
          <div role="progressbar" aria-labelledby="tpb-mod-2" aria-valuenow="3" aria-valuemin="0" aria-valuemax="5" aria-valuetext="3 of 5 lessons" class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"><div class="h-full rounded-full bg-teal-600 dark:bg-teal-400" style="width:60%"></div></div>
          <a href="#" class="mt-2 inline-flex rounded text-sm font-semibold text-teal-700 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-600 dark:text-teal-300">Resume lesson 4</a>
        </div>
      </li>
      <li class="flex items-center gap-4 py-4">
        <span class="grid size-9 shrink-0 place-items-center rounded-full bg-slate-100 text-sm font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300" aria-hidden="true">3</span>
        <div class="min-w-0 flex-1">
          <div class="flex flex-wrap items-baseline justify-between gap-x-3">
            <p id="tpb-mod-3" class="font-medium text-slate-900 dark:text-white">Custom selects and comboboxes</p>
            <p class="text-xs text-slate-600 dark:text-slate-400">Not started</p>
          </div>
          <div role="progressbar" aria-labelledby="tpb-mod-3" aria-valuenow="0" aria-valuemin="0" aria-valuemax="3" aria-valuetext="0 of 3 lessons" class="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800"><div class="h-full rounded-full bg-teal-600 dark:bg-teal-400" style="width:0%"></div></div>
        </div>
      </li>
      <li class="flex items-center gap-4 py-4">
        <span class="grid size-9 shrink-0 place-items-center rounded-full bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300" aria-hidden="true"><svg class="size-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8"><rect x="4.5" y="9" width="11" height="8" rx="1.5"/><path d="M7 9V6.5a3 3 0 0 1 6 0V9"/></svg></span>
        <div class="min-w-0 flex-1">
          <div class="flex flex-wrap items-baseline justify-between gap-x-3">
            <p id="tpb-mod-4" class="font-medium text-slate-600 dark:text-slate-400">Final project</p>
            <p class="text-xs text-slate-600 dark:text-slate-400">Locked until module 3</p>
          </div>
          <div class="mt-2 h-1.5 rounded-full border border-dashed border-slate-300 dark:border-slate-600" aria-hidden="true"></div>
        </div>
      </li>
    </ul>
  </div>
</div>

Skill bars that grow in with starting:

  • starting:scale-x-0
  • origin-left
  • motion-safe:transition-transform
  • grid-cols-5
  • dl

A skill level is a judgement, not a task in progress, so the list is a dl with the level in words and the bars are aria-hidden decoration. Each fill has an inline width and grows in on first render through motion-safe:starting:scale-x-0 with origin-left and a transition-transform, which needs no JS and no keyframes, and the staggered [transition-delay:...] values make the rows arrive one after another. The language rows use a grid-cols-5 pip scale instead of a continuous bar.

View code
<div class="w-full max-w-lg rounded-2xl bg-stone-900 p-6 text-stone-100 ring-1 ring-white/10 sm:p-8">
  <div class="flex items-center gap-4">
    <span class="grid size-12 shrink-0 place-items-center rounded-full bg-lime-300 text-sm font-bold text-stone-900" aria-hidden="true">MR</span>
    <div>
      <h2 class="text-base font-semibold text-white">Mara Ruiz</h2>
      <p class="text-sm text-stone-400">Product designer, 9 years</p>
    </div>
  </div>
  <h3 class="mt-6 text-xs font-semibold tracking-wide text-stone-400 uppercase">Tools</h3>
  <dl class="mt-3 space-y-4">
    <div>
      <div class="flex justify-between gap-4 text-sm"><dt class="font-medium text-white">Figma prototyping</dt><dd class="text-lime-300">Expert</dd></div>
      <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-stone-700" aria-hidden="true"><div class="h-full origin-left rounded-full bg-lime-300 motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out motion-safe:starting:scale-x-0" style="width:94%"></div></div>
    </div>
    <div>
      <div class="flex justify-between gap-4 text-sm"><dt class="font-medium text-white">Design systems</dt><dd class="text-lime-300">Advanced</dd></div>
      <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-stone-700" aria-hidden="true"><div class="h-full origin-left rounded-full bg-lime-300 motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out motion-safe:[transition-delay:120ms] motion-safe:starting:scale-x-0" style="width:82%"></div></div>
    </div>
    <div>
      <div class="flex justify-between gap-4 text-sm"><dt class="font-medium text-white">User research</dt><dd class="text-lime-300">Advanced</dd></div>
      <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-stone-700" aria-hidden="true"><div class="h-full origin-left rounded-full bg-lime-300 motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out motion-safe:[transition-delay:240ms] motion-safe:starting:scale-x-0" style="width:76%"></div></div>
    </div>
    <div>
      <div class="flex justify-between gap-4 text-sm"><dt class="font-medium text-white">HTML and Tailwind</dt><dd class="text-stone-300">Working knowledge</dd></div>
      <div class="mt-2 h-1.5 overflow-hidden rounded-full bg-stone-700" aria-hidden="true"><div class="h-full origin-left rounded-full bg-stone-300 motion-safe:transition-transform motion-safe:duration-700 motion-safe:ease-out motion-safe:[transition-delay:360ms] motion-safe:starting:scale-x-0" style="width:55%"></div></div>
    </div>
  </dl>
  <h3 class="mt-7 text-xs font-semibold tracking-wide text-stone-400 uppercase">Languages</h3>
  <dl class="mt-3 space-y-3">
    <div class="flex items-center justify-between gap-4 text-sm">
      <dt class="w-20 font-medium text-white">Spanish</dt>
      <div class="grid w-28 grid-cols-5 gap-1" aria-hidden="true"><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span></div>
      <dd class="w-24 text-right text-stone-300">Native</dd>
    </div>
    <div class="flex items-center justify-between gap-4 text-sm">
      <dt class="w-20 font-medium text-white">English</dt>
      <div class="grid w-28 grid-cols-5 gap-1" aria-hidden="true"><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-stone-700"></span></div>
      <dd class="w-24 text-right text-stone-300">Fluent, C1</dd>
    </div>
    <div class="flex items-center justify-between gap-4 text-sm">
      <dt class="w-20 font-medium text-white">Portuguese</dt>
      <div class="grid w-28 grid-cols-5 gap-1" aria-hidden="true"><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-lime-300"></span><span class="h-2 rounded-sm bg-stone-700"></span><span class="h-2 rounded-sm bg-stone-700"></span><span class="h-2 rounded-sm bg-stone-700"></span></div>
      <dd class="w-24 text-right text-stone-300">Basic, A2</dd>
    </div>
  </dl>
</div>

Live updates: uploads, scrolling and animated counters

Bars that move while the visitor watches. Width changes ride on motion-safe:transition-[width], the reading bar follows a scroll container, and an @property integer lets the number and the fill count up together.

File upload with cancel and a live region

  • motion-safe:transition-[width]
  • group-data-[state=*]:
  • aria-live
  • setTimeout
  • aria-valuenow

Click the buttons or scroll the article to update the bars.

A chain of setTimeout calls stands in for the xhr.upload progress event: every tick adds a random chunk, writes the width, aria-valuenow and the size and time text, and schedules the next tick until 100%. data-state on the card (idle, uploading, done, canceled) drives the fill color and the button labels through group-data-[state=*]: variants, and a polite live region speaks only at quarter marks, on completion and on cancel, so a screen reader is not flooded with every tick.

View code
<div id="tpb-up" data-state="idle" class="group w-full max-w-lg rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-200 sm:p-8">
  <h2 class="text-base font-semibold text-slate-900">Upload site photos</h2>
  <div class="mt-4 flex items-center gap-4 rounded-xl bg-slate-50 p-4 ring-1 ring-slate-200">
    <span class="grid size-11 shrink-0 place-items-center rounded-lg bg-indigo-100 text-indigo-700 group-data-[state=done]:bg-emerald-100 group-data-[state=done]:text-emerald-700" aria-hidden="true">
      <svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5Zm0 0v5h5M9 13l3-3 3 3m-3-3v7"/></svg>
    </span>
    <div class="min-w-0 flex-1">
      <div class="flex items-baseline justify-between gap-3">
        <p id="tpb-up-name" class="truncate text-sm font-medium text-slate-900">harbor-shoot-raw.zip</p>
        <p id="tpb-up-pct" class="shrink-0 text-sm font-semibold text-slate-700 tabular-nums">0%</p>
      </div>
      <div id="tpb-up-bar" role="progressbar" aria-labelledby="tpb-up-name" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" class="mt-2 h-2 overflow-hidden rounded-full bg-slate-200">
        <div id="tpb-up-fill" class="h-full rounded-full bg-indigo-600 group-data-[state=canceled]:bg-slate-400 group-data-[state=done]:bg-emerald-600 motion-safe:transition-[width] motion-safe:duration-300 motion-safe:ease-linear" style="width:0%"></div>
      </div>
      <p id="tpb-up-meta" class="mt-1.5 text-xs text-slate-600 tabular-nums">48.6 MB, ready to upload</p>
    </div>
  </div>
  <div class="mt-5 flex flex-wrap gap-3">
    <button type="button" id="tpb-up-start" class="inline-flex min-h-11 items-center rounded-lg bg-indigo-600 px-5 text-sm font-semibold text-white hover:bg-indigo-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 group-data-[state=uploading]:hidden">
      <span class="group-data-[state=canceled]:hidden group-data-[state=done]:hidden">Start upload</span>
      <span class="hidden group-data-[state=canceled]:inline">Retry upload</span>
      <span class="hidden group-data-[state=done]:inline">Upload again</span>
    </button>
    <button type="button" id="tpb-up-cancel" class="hidden min-h-11 items-center rounded-lg px-5 text-sm font-semibold text-red-700 ring-1 ring-red-300 hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600 group-data-[state=uploading]:inline-flex">Cancel upload</button>
  </div>
  <p id="tpb-up-live" class="sr-only" aria-live="polite"></p>
</div>

Reading progress inside a scroll container

  • sticky top-0
  • overflow-y-auto
  • origin-left
  • animation-timeline: scroll()
  • @supports

Click the buttons or scroll the article to update the bars.

The bar sits in a sticky top-0 header inside the overflow-y-auto article, so scroll() with no arguments picks that container as its timeline and the fill grows from scale-x-0 to full width with origin-left, all in CSS inside @supports. The keyframes animate the scale property because that is what scale-x-0 sets in v4. Browsers without scroll timelines, and visitors who ask for reduced motion, get the same scale from a passive scroll listener, which also writes the minutes left. The strip is aria-hidden because the scrollbar already reports the position.

View code
<div class="w-full max-w-lg overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200">
  <div id="tpb-read" tabindex="0" aria-labelledby="tpb-read-title" class="h-80 overflow-y-auto overscroll-contain focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-emerald-700">
    <div class="sticky top-0 z-10 bg-white/95 backdrop-blur">
      <div class="flex items-center justify-between gap-3 px-6 pt-4 pb-3 text-xs text-slate-600">
        <span class="font-semibold text-slate-900">Field notes</span>
        <span id="tpb-read-left" class="tabular-nums">4 min left</span>
      </div>
      <div class="h-1 bg-slate-100" aria-hidden="true"><div id="tpb-read-fill" class="tpb-read-fill h-full origin-left scale-x-0 bg-emerald-600"></div></div>
    </div>
    <article class="space-y-4 px-6 pt-5 pb-8 text-[15px] leading-relaxed text-slate-700">
      <h2 id="tpb-read-title" class="text-lg font-semibold text-slate-900">Mapping the old canal path</h2>
      <p>The towpath starts behind the grain store and runs for nine kilometers before it disappears under a newer road. Most of it is still walkable, if you do not mind nettles.</p>
      <p>We walked it in three sections over a weekend, logging every lock, bridge and mile marker with a phone and a cheap GPS unit that refused to agree with the phone.</p>
      <p>Lock four is the best preserved. The gates are gone, but the stone chamber is complete and the iron bollards still carry rope grooves from the towing lines.</p>
      <p>Between locks five and six the path narrows to a single track. Somebody has mown it recently, which suggests the parish still treats it as a right of way.</p>
      <p>The last surviving bridge carries a date stone. Half of it is lichen, the other half reads clearly enough to match the year in the county survey.</p>
      <p>Past the road the canal bed turns into a line of willows. You can follow it on satellite photos, a darker stripe through the fields, all the way to the river.</p>
      <p>Next month we go back with a measuring wheel to record the width of each lock chamber, and to find out who mows the narrow section.</p>
    </article>
  </div>
</div>

Animated counter and bar with @property

  • @property
  • w-[calc(var(--tpb-pct)*1%)]
  • motion-safe:transition-[--tpb-pct]
  • counter()
  • aria-valuenow

Click the buttons or scroll the article to update the bars.

An @property rule registers --tpb-pct as an integer, which makes it animatable: motion-safe:transition-[--tpb-pct] eases the number itself, the fill reads it through w-[calc(var(--tpb-pct)*1%)], and the tpb-count utility prints it with counter-reset and counter(). One custom property drives both the digits and the width, so they always agree mid-animation. The script only sets the final value and aria-valuenow, and under reduced motion the jump is instant.

View code
<div id="tpb-kpi" class="w-full max-w-md rounded-2xl bg-slate-950 p-6 text-white ring-1 ring-white/10 [--tpb-pct:62] motion-safe:transition-[--tpb-pct] motion-safe:duration-1000 motion-safe:ease-out sm:p-8">
  <div class="flex items-start justify-between gap-4">
    <div>
      <h2 id="tpb-kpi-title" class="text-sm font-medium text-slate-300">Q4 sales target</h2>
      <p class="tpb-count mt-2 text-5xl font-bold tracking-tight tabular-nums" aria-hidden="true"></p>
    </div>
    <span class="rounded-full bg-cyan-400/10 px-2.5 py-1 text-xs font-medium text-cyan-200 ring-1 ring-cyan-400/30">Live</span>
  </div>
  <div id="tpb-kpi-bar" role="progressbar" aria-labelledby="tpb-kpi-title" aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" class="mt-5 h-3 overflow-hidden rounded-full bg-white/10">
    <div class="h-full w-[calc(var(--tpb-pct)*1%)] rounded-full bg-cyan-400"></div>
  </div>
  <p id="tpb-kpi-text" class="mt-3 text-sm text-slate-300 tabular-nums">$1.24M of $2M booked</p>
  <div class="mt-6 grid grid-cols-3 gap-2" role="group" aria-label="Show scenario">
    <button type="button" class="tpb-kpi-set min-h-11 rounded-lg text-sm font-semibold text-slate-200 ring-1 ring-white/15 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300 aria-pressed:bg-cyan-400 aria-pressed:text-slate-950 aria-pressed:ring-cyan-400" data-value="18" aria-pressed="false">October</button>
    <button type="button" class="tpb-kpi-set min-h-11 rounded-lg text-sm font-semibold text-slate-200 ring-1 ring-white/15 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300 aria-pressed:bg-cyan-400 aria-pressed:text-slate-950 aria-pressed:ring-cyan-400" data-value="62" aria-pressed="true">Today</button>
    <button type="button" class="tpb-kpi-set min-h-11 rounded-lg text-sm font-semibold text-slate-200 ring-1 ring-white/15 hover:bg-white/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300 aria-pressed:bg-cyan-400 aria-pressed:text-slate-950 aria-pressed:ring-cyan-400" data-value="100" aria-pressed="false">Forecast</button>
  </div>
</div>

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.

MethodSyntaxValue knownLimit
Fractionw-1/2Build timeFixed steps only
Arbitrary valuew-[45%]Build timeOne full class per value
Inline stylestyle="width: 45%"RuntimeNo variants such as md:
CSS variablew-(--progress)RuntimeVariable 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.

PartChrome and SafariFirefox
Track::-webkit-progress-barThe 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.

  1. 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.
  2. Add a fill inside it with h-full w-0 bg-blue-600 and an id the script can find.
  3. Write the scroll listener, which divides scroll position by scrollable height and writes the result to the fill width.
  4. Or skip the listener and use one CSS rule that animates width from 0 to 100% on a scroll timeline.
  5. 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.

OptionStrengthCost
Hand-built divFull control over fill, gradients and labelsARIA and value sync by hand
Native progress elementBuilt-in role and indeterminate stateSeparate selectors per engine
daisyUI progressOne class on the progress tag, 8 color modifiersNeeds the daisyUI plugin
shadcn/ui ProgressRadix primitive that follows progressbar role rulesReact only, plus the 2.86 kB package
FlyonUI progressReady-made progress component with its own modifier classesAnother 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.