DEV Community

Cover image for Build an Animated Multi-Step Progress Tracker Using CSS
Artclick
Artclick

Posted on

Build an Animated Multi-Step Progress Tracker Using CSS

Multi-step forms and checkout flows almost always need a progress tracker at the top — a row of steps that fills in as the user moves forward. Traditionally, that means adding or removing a .active / .completed class with JavaScript every time the step changes.

The :has() selector changes that. It lets CSS react to state living somewhere else in the DOM — like a radio input's checked state — and style earlier elements based on it. That means the entire tracker, including the connecting line filling in behind completed steps, can update with zero JavaScript.

What We're Building

A horizontal row of numbered steps connected by a line. Selecting a step (via radio inputs, so it's also keyboard-navigable) marks every step up to and including it as "completed," fills in the connecting line, and animates the transition — all driven by :has() and sibling combinators.

Step 1: The HTML Structure

The trick is pairing each visible step with a hidden radio input, all inside one wrapper :has() can inspect.

<fieldset class="tracker">
  <legend class="sr-only">Order progress</legend>

  <label class="tracker__step">
    <input type="radio" name="step" value="1" checked />
    <span class="tracker__circle">1</span>
    <span class="tracker__label">Cart</span>
  </label>

  <label class="tracker__step">
    <input type="radio" name="step" value="2" />
    <span class="tracker__circle">2</span>
    <span class="tracker__label">Shipping</span>
  </label>

  <label class="tracker__step">
    <input type="radio" name="step" value="3" />
    <span class="tracker__circle">3</span>
    <span class="tracker__label">Payment</span>
  </label>

  <label class="tracker__step">
    <input type="radio" name="step" value="4" />
    <span class="tracker__circle">4</span>
    <span class="tracker__label">Confirm</span>
  </label>
</fieldset>
Enter fullscreen mode Exit fullscreen mode

Why Radio Inputs

Radios inside a <fieldset> give you native keyboard navigation (arrow keys move between them), a natural "only one selected at a time" model that matches how a step tracker behaves, and — most importantly — a :checked state that :has() can query without any JavaScript.

Step 2: Base Layout

.tracker {
  display: flex;
  border: none;
  padding: 0;
  margin: 0 auto;
  max-width: 600px;
}

.tracker__step {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  cursor: pointer;
}

.tracker__step input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.tracker__circle {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 2px solid #ccc;
  background: #fff;
  font-size: 0.85rem;
  font-weight: 600;
  color: #999;
  transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease;
  z-index: 1;
}

.tracker__label {
  margin-top: 6px;
  font-size: 0.8rem;
  color: #777;
}
Enter fullscreen mode Exit fullscreen mode

The radio itself is visually hidden but stays in the accessibility tree and keyboard flow — opacity: 0 rather than display: none, since a hidden-but-present input still gets keyboard focus and screen reader support.

Step 3: The Connecting Line

Each step (except the first) gets a line segment behind it, drawn with a pseudo-element positioned to reach back to the previous circle's center.

.tracker__step:not(:first-child)::before {
  content: "";
  position: absolute;
  top: 16px;
  right: 50%;
  width: 100%;
  height: 2px;
  background: #ccc;
  z-index: 0;
}
Enter fullscreen mode Exit fullscreen mode

right: 50% combined with width: 100% stretches the line from the current step's center back to the previous one, sitting under the circles (z-index: 0 vs the circle's z-index: 1).

Step 4: Style the Checked Step

.tracker__step:has(input:checked) .tracker__circle {
  border-color: #4f46e5;
  background: #4f46e5;
  color: #fff;
  transform: scale(1.1);
}

.tracker__step:has(input:checked) .tracker__label {
  color: #4f46e5;
  font-weight: 600;
}
Enter fullscreen mode Exit fullscreen mode

:has(input:checked) lets the .tracker__step wrapper style itself based on its own child's state — something that used to require JavaScript, since CSS previously had no "parent selector."

Step 5: Mark Earlier Steps as Completed

This is the part that would normally need JavaScript tracking an array of completed steps. With :has() and the general sibling combinator, CSS can express "every step before the one that's checked":

.tracker__step:has(~ .tracker__step input:checked) .tracker__circle {
  border-color: #4f46e5;
  background: #4f46e5;
  color: #fff;
}

.tracker__step:has(~ .tracker__step input:checked) .tracker__circle::after {
  content: "✓";
}

.tracker__step:has(~ .tracker__step input:checked)::before,
.tracker__step:has(~ .tracker__step input:checked) + .tracker__step::before {
  background: #4f46e5;
}
Enter fullscreen mode Exit fullscreen mode

Reading :has(~ .tracker__step input:checked) out loud: "select any .tracker__step that has a later sibling .tracker__step containing a checked input." Since checking step 3 means steps 1 and 2 both have a later sibling that's checked, they both match — automatically, without tracking indices anywhere.

Step 6: Animate the Circle's Checkmark

Swapping the number for a checkmark feels abrupt without a transition. A small keyframe animation on the pseudo-element smooths it out:

.tracker__circle {
  position: relative;
}

.tracker__circle::after {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: inherit;
  border-radius: 50%;
  opacity: 0;
  transform: scale(0.5);
  transition: opacity 0.25s ease, transform 0.25s ease;
}

.tracker__step:has(~ .tracker__step input:checked) .tracker__circle::after {
  opacity: 1;
  transform: scale(1);
}
Enter fullscreen mode Exit fullscreen mode

The number underneath and the checkmark overlay cross-fade and scale in together, rather than the content just flipping instantly.

Step 7: Animate the Line Fill

For a smoother "filling in" effect rather than an instant color swap, animate the line's width from the inside instead of its background color directly:

.tracker__step::before {
  background: linear-gradient(to right, #4f46e5 0%, #4f46e5 0%, #ccc 0%);
}

.tracker__step:has(~ .tracker__step input:checked)::before {
  background: linear-gradient(to right, #4f46e5 100%, #4f46e5 100%, #ccc 100%);
  transition: background 0.4s ease;
}
Enter fullscreen mode Exit fullscreen mode

This keeps the line a single element while still giving the impression of it filling left-to-right, using a hard-stop gradient rather than actually resizing anything.

Step 8: Wire Up Clicking Between Steps

Since each step is a <label> wrapping its input, clicking anywhere on a step — the circle or the text — activates that radio automatically, which is standard label/input behavior and needs no extra code.

For a "Next" / "Back" button pair instead of clickable steps directly, you can pair hidden radios with <label for="step-2">Next</label> elements placed outside the tracker, since labels can target any input by id regardless of where they sit in the document.

Accessibility Notes

  • Wrapping everything in a <fieldset> with a <legend> (visually hidden via a .sr-only utility class) gives screen readers a clear grouping and purpose for the control.
  • Real radio inputs mean arrow-key navigation and focus indicators work without extra ARIA.
  • If the tracker only reflects progress and isn't meant to let users jump between steps, use disabled on inputs for steps not yet reached, and consider a <div>/aria-current="step" based version instead of interactive radios.

Wrapping Up

:has() combined with the general sibling combinator (~) turns "style everything before the active state" from a JavaScript state-tracking problem into a single CSS selector. The result is a fully interactive, keyboard-accessible, animated progress tracker with no state management code at all.

From here, worth exploring: a vertical variant for sidebar wizards, disabling :has() fallback styling for older browsers with @supports selector(:has(a)), or combining this pattern with :target for a tracker that syncs with the URL.

Full Code

Everything from the steps above, combined into one working file.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Animated Progress Tracker</title>
  <link rel="stylesheet" href="styles.css" />
</head>
<body>

  <fieldset class="tracker">
    <legend class="sr-only">Order progress</legend>

    <label class="tracker__step">
      <input type="radio" name="step" value="1" checked />
      <span class="tracker__circle">1</span>
      <span class="tracker__label">Cart</span>
    </label>

    <label class="tracker__step">
      <input type="radio" name="step" value="2" />
      <span class="tracker__circle">2</span>
      <span class="tracker__label">Shipping</span>
    </label>

    <label class="tracker__step">
      <input type="radio" name="step" value="3" />
      <span class="tracker__circle">3</span>
      <span class="tracker__label">Payment</span>
    </label>

    <label class="tracker__step">
      <input type="radio" name="step" value="4" />
      <span class="tracker__circle">4</span>
      <span class="tracker__label">Confirm</span>
    </label>
  </fieldset>

</body>
</html>
Enter fullscreen mode Exit fullscreen mode
/* Visually hide the legend without removing it from the accessibility tree */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Layout */
.tracker {
  display: flex;
  border: none;
  padding: 0;
  margin: 0 auto;
  max-width: 600px;
}

.tracker__step {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  cursor: pointer;
}

.tracker__step input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

/* Circle */
.tracker__circle {
  position: relative;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 2px solid #ccc;
  background: #fff;
  font-size: 0.85rem;
  font-weight: 600;
  color: #999;
  transition: background 0.3s ease, border-color 0.3s ease, color 0.3s ease;
  z-index: 1;
}

.tracker__label {
  margin-top: 6px;
  font-size: 0.8rem;
  color: #777;
}

/* Connecting line, drawn as a hard-stop gradient so it can "fill" */
.tracker__step:not(:first-child)::before {
  content: "";
  position: absolute;
  top: 16px;
  right: 50%;
  width: 100%;
  height: 2px;
  z-index: 0;
  background: linear-gradient(to right, #4f46e5 0%, #4f46e5 0%, #ccc 0%);
  transition: background 0.4s ease;
}

/* Currently selected step */
.tracker__step:has(input:checked) .tracker__circle {
  border-color: #4f46e5;
  background: #4f46e5;
  color: #fff;
  transform: scale(1.1);
}

.tracker__step:has(input:checked) .tracker__label {
  color: #4f46e5;
  font-weight: 600;
}

/* Checkmark overlay, cross-fades in over the number */
.tracker__circle::after {
  content: "✓";
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: inherit;
  border-radius: 50%;
  opacity: 0;
  transform: scale(0.5);
  transition: opacity 0.25s ease, transform 0.25s ease;
}

/* Every step before the checked one: completed */
.tracker__step:has(~ .tracker__step input:checked) .tracker__circle {
  border-color: #4f46e5;
  background: #4f46e5;
  color: #fff;
}

.tracker__step:has(~ .tracker__step input:checked) .tracker__circle::after {
  opacity: 1;
  transform: scale(1);
}

.tracker__step:has(~ .tracker__step input:checked)::before,
.tracker__step:has(~ .tracker__step input:checked) + .tracker__step::before {
  background: linear-gradient(to right, #4f46e5 100%, #4f46e5 100%, #ccc 100%);
}
Enter fullscreen mode Exit fullscreen mode

Fallback tip: wrap the :has()-dependent rules in @supports selector(:has(a)) if you need graceful degradation in older browsers — everything outside that block (layout, circles, labels) still renders fine, just without the automatic "completed" styling.


At ArtClick, we build fast, scalable WordPress websites, company websites and custom web systems that balance design, performance and long-term maintainability. Whether you're starting from scratch or improving an existing platform, we'd love to help.

https://artclickdev.com/

Top comments (0)