ui-020

Progress Stepper

Free

A progress stepper for checkouts and onboarding: the track fills to the active step and completed steps flip into a drawn checkmark.

intermediate
4 more details
progress-bartimeline-controlsvg-strokeform-animation

This element reads better at your own screen size than in the frame below:

Updated

About this component

A multi-step progress indicator for checkouts, onboarding and long forms. The track fill tweens to the active step, the node you just left flips its number over into a checkmark that draws itself, and the node you arrive at pulses once so the eye follows the move.

Read the full effect overview

Completed steps stay clickable so someone can go back and fix an answer; steps ahead are disabled buttons, so nothing unreachable is focusable. Under 600px the whole thing stacks into a vertical list with the rail running down the side. One HTML file, one stylesheet, one script, GSAP core only, and a light and a dark theme switched by a data-variant attribute.

Progress Stepper - UI component preview

What's included

8 items
  • Track fill tweens to the active step in whichever axis the layout is using
  • Completed nodes flip on rotationX from number to a stroke-drawn checkmark
  • The active node pulses once on arrival
  • Click any completed node to jump back; steps ahead are disabled
  • Rail is measured from first node centre to last, so any node size works
  • Compact vertical layout under 600px, remeasured on resize
  • window.stepProgress.goTo / next / back for your own form code
  • Light and dark themes, every colour a custom property

Perfect for

4 use cases
  • Multi-step checkout and payment flows
  • Onboarding and account setup wizards
  • Long application or survey forms split into sections
  • Upload, review and publish pipelines in dashboards

How it works

4 sections

A measured rail, not a padded one

The rail is positioned from the centre of the first node to the centre of the last with getBoundingClientRect, so it never pokes past the end nodes whatever their size, gap or label width. It is remeasured on load, after web fonts land, on resize and whenever the theme changes.

One fill, two axes

The fill is a child of the rail. Horizontally GSAP tweens its width to a percentage of the step index; below 600px the same call tweens its height instead, and the unused axis is pinned to 100 percent so rotating the layout never leaves a stale dimension behind.

Flipping a number into a check

The number and the checkmark sit stacked inside the node with backface-visibility hidden and a perspective on the parent. Completing a step rotates the number out on rotationX, rotates the check in, and tweens the check path's strokeDashoffset from its measured length to zero so the tick draws. Jumping back plays the same flip in reverse and clears the inline colours GSAP set.

Reachable means focusable

Every node is a real button. Steps ahead of the current one carry the disabled attribute plus pointer-events none, so they are skipped by keyboard navigation and cannot swallow a click; completed steps stay live and jump the flow back. The active node carries aria-current step and an aria-live line announces the position on every change.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Progress Stepper &mdash; GSAP Vault</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
  <link rel="stylesheet" href="assets/style.css">
  <style>
    /* Demo-only toolbar; not part of the component. */
    .showcase-toolbar {
      position: fixed; inset: 0 0 auto 0; z-index: 1000; height: 44px;
      display: flex; align-items: center; gap: 10px; padding: 0 12px;
      flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none;
      background: var(--ground-2); border-bottom: 1px solid var(--line);
      color: var(--ink-3); font-family: var(--font); font-size: 0.75rem;
      white-space: nowrap;
    }
    .showcase-toolbar::-webkit-scrollbar { display: none; }
    .showcase-toolbar__prompt { color: var(--ink-2); margin-right: auto; }
    .showcase-toolbar__group { display: flex; gap: 2px; padding: 2px; border-radius: 6px; border: 1px solid var(--line); background: var(--raised); }
    .showcase-toolbar button {
      appearance: none; border: 0; cursor: pointer; font: inherit;
      min-height: 26px; padding: 0 9px; border-radius: 4px;
      background: transparent; color: var(--ink-2);
    }
    .showcase-toolbar button:hover { background: var(--hover); color: var(--ink); }
    .showcase-toolbar button[aria-pressed="true"] { background: var(--ink); color: var(--ground); }
    .showcase-toolbar button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    /* One row on a phone: the prompt goes so the controls stay on screen. */
    @media (max-width: 560px) {
      .showcase-toolbar__prompt { display: none; }
      .showcase-toolbar { justify-content: flex-end; }
    }
  </style>
</head>
<body data-variant="light">

  <header class="showcase-toolbar">
    <span class="showcase-toolbar__prompt">Move through the flow with Continue and Back, or click a finished step to go back to it.</span>
    <div class="showcase-toolbar__group" role="group" aria-label="Theme">
      <button class="variant-btn" type="button" data-variant="light" aria-pressed="true">Light</button>
      <button class="variant-btn" type="button" data-variant="dark" aria-pressed="false">Dark</button>
    </div>
  </header>

  <main class="stage">
    <div class="stage__inner">

      <nav class="stepper" id="stepper" aria-label="Account setup progress">
        <div class="stepper-rail" id="stepperRail" aria-hidden="true">
          <div class="stepper-fill" id="stepperFill"></div>
        </div>
        <ol class="stepper-list" id="stepperList">
          <li class="step">
            <button type="button" class="step-node">
              <span class="step-num">1</span>
              <span class="step-check" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none">
                  <path class="step-check-path" d="M5 12.5 L10 17.5 L19 7"
                        stroke="currentColor" stroke-width="2.6"
                        stroke-linecap="round" stroke-linejoin="round"></path>
                </svg>
              </span>
              <span class="step-node-text">Step 1, Company</span>
            </button>
            <span class="step-label" aria-hidden="true">Company</span>
          </li>
          <li class="step">
            <button type="button" class="step-node">
              <span class="step-num">2</span>
              <span class="step-check" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none">
                  <path class="step-check-path" d="M5 12.5 L10 17.5 L19 7"
                        stroke="currentColor" stroke-width="2.6"
                        stroke-linecap="round" stroke-linejoin="round"></path>
                </svg>
              </span>
              <span class="step-node-text">Step 2, Bank account</span>
            </button>
            <span class="step-label" aria-hidden="true">Bank account</span>
          </li>
          <li class="step">
            <button type="button" class="step-node">
              <span class="step-num">3</span>
              <span class="step-check" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none">
                  <path class="step-check-path" d="M5 12.5 L10 17.5 L19 7"
                        stroke="currentColor" stroke-width="2.6"
                        stroke-linecap="round" stroke-linejoin="round"></path>
                </svg>
              </span>
              <span class="step-node-text">Step 3, Template</span>
            </button>
            <span class="step-label" aria-hidden="true">Template</span>
          </li>
          <li class="step">
            <button type="button" class="step-node">
              <span class="step-num">4</span>
              <span class="step-check" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none">
                  <path class="step-check-path" d="M5 12.5 L10 17.5 L19 7"
                        stroke="currentColor" stroke-width="2.6"
                        stroke-linecap="round" stroke-linejoin="round"></path>
                </svg>
              </span>
              <span class="step-node-text">Step 4, Team</span>
            </button>
            <span class="step-label" aria-hidden="true">Team</span>
          </li>
          <li class="step">
            <button type="button" class="step-node">
              <span class="step-num">5</span>
              <span class="step-check" aria-hidden="true">
                <svg viewBox="0 0 24 24" width="14" height="14" fill="none">
                  <path class="step-check-path" d="M5 12.5 L10 17.5 L19 7"
                        stroke="currentColor" stroke-width="2.6"
                        stroke-linecap="round" stroke-linejoin="round"></path>
                </svg>
              </span>
              <span class="step-node-text">Step 5, Done</span>
            </button>
            <span class="step-label" aria-hidden="true">Done</span>
          </li>
        </ol>
      </nav>

      <p class="stepper-status" id="stepperStatus" role="status" aria-live="polite"></p>

      <div class="demo-controls">
        <button type="button" id="stepBack">Back</button>
        <button type="button" id="stepNext" class="is-primary">Continue</button>
      </div>

    </div>
  </main>

  <script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
  <script src="assets/script.js"></script>
  <script>
    // Demo-only: switches data-variant on <body>; also honours ?variant=name.
    (function () {
      var buttons = Array.prototype.slice.call(document.querySelectorAll('.variant-btn'));
      var names = buttons.map(function (b) { return b.dataset.variant; });
      function setVariant(name) {
        document.body.setAttribute('data-variant', name);
        buttons.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.variant === name)); });
        // The rail is measured, so it re-measures on the resize the switch fires.
        requestAnimationFrame(function () { window.dispatchEvent(new Event('resize')); });
      }
      var wanted = new URLSearchParams(location.search).get('variant');
      var initial = names.indexOf(wanted) > -1 ? wanted : document.body.getAttribute('data-variant') || names[0];
      setVariant(initial);
      buttons.forEach(function (b) { b.addEventListener('click', function () { setVariant(b.dataset.variant); }); });
    })();
  </script>
</body>
</html>
/**
 * Step Progress Indicator
 *
 * Numbered steps joined by a track whose fill tweens to the active step.
 * Completed nodes flip their number into a drawn checkmark; the active node
 * pulses once. Collapses to a vertical list on narrow screens.
 *
 * @plugins none (core GSAP only)
 */

(function () {
(function onReady(init) {
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init, { once: true });
    } else {
        init();
    }
})(function initStepProgressIndicator() {
    if (typeof gsap === 'undefined') {
        console.warn('[step-progress-indicator] GSAP is required but was not found.');
        return;
    }

    var stepper = document.getElementById('stepper');
    var rail = document.getElementById('stepperRail');
    var fill = document.getElementById('stepperFill');
    var list = document.getElementById('stepperList');
    var statusEl = document.getElementById('stepperStatus');
    var backBtn = document.getElementById('stepBack');
    var nextBtn = document.getElementById('stepNext');
    if (!stepper || !rail || !fill || !list) return;

    var steps = Array.prototype.slice.call(list.querySelectorAll('.step'));
    var nodes = steps.map(function (s) { return s.querySelector('.step-node'); });
    var last = steps.length - 1;

    var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
    /* One duration multiplier: under reduced motion everything switches
       instantly and no state is skipped. */
    var d = reduced ? 0 : 1;
    /* Below this width the indicator stacks and the rail runs vertically. */
    var VERTICAL_QUERY = '(max-width: 600px)';

    var current = 0;

    /* Declared before first use: build:demos un-hoists function declarations. */
    function token(name) {
        return getComputedStyle(document.body).getPropertyValue(name).trim();
    }

    function isVertical() {
        return window.matchMedia(VERTICAL_QUERY).matches;
    }

    function checkPathOf(step) {
        return step.querySelector('.step-check-path');
    }

    function armCheck(step) {
        var path = checkPathOf(step);
        if (!path) return 0;
        var len = path.getTotalLength();
        gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
        return len;
    }

    /* Measure the rail from the centre of the first node to the centre of the
       last one, in whichever axis the layout is currently using. Called on
       load, after web fonts land, on resize and on a variant change. */
    function layout() {
        var base = stepper.getBoundingClientRect();
        var a = nodes[0].getBoundingClientRect();
        var b = nodes[last].getBoundingClientRect();
        var thickness = parseFloat(token('--track-w')) || 3;

        if (isVertical()) {
            var y1 = a.top + a.height / 2 - base.top;
            var y2 = b.top + b.height / 2 - base.top;
            rail.style.left = (a.left + a.width / 2 - base.left - thickness / 2) + 'px';
            rail.style.top = y1 + 'px';
            rail.style.width = thickness + 'px';
            rail.style.height = Math.max(0, y2 - y1) + 'px';
        } else {
            var x1 = a.left + a.width / 2 - base.left;
            var x2 = b.left + b.width / 2 - base.left;
            rail.style.left = x1 + 'px';
            rail.style.top = (a.top + a.height / 2 - base.top - thickness / 2) + 'px';
            rail.style.width = Math.max(0, x2 - x1) + 'px';
            rail.style.height = thickness + 'px';
        }
    }

    function setFill(index, animate) {
        var pct = last > 0 ? (index / last) * 100 : 0;
        var dur = animate ? 0.55 * d : 0;
        /* Pin the axis the fill is not using, or rotating the layout leaves a
           stale width or height behind. */
        if (isVertical()) {
            gsap.set(fill, { width: '100%' });
            gsap.to(fill, { height: pct + '%', duration: dur, ease: 'power3.inOut' });
        } else {
            gsap.set(fill, { height: '100%' });
            gsap.to(fill, { width: pct + '%', duration: dur, ease: 'power3.inOut' });
        }
    }

    function flipToCheck(step, animate) {
        var node = step.querySelector('.step-node');
        var num = step.querySelector('.step-num');
        var chk = step.querySelector('.step-check');
        var path = checkPathOf(step);
        var len = armCheck(step);
        var t = animate ? d : 0;

        gsap.timeline()
            .to(num, { rotationX: -90, opacity: 0, duration: 0.2 * t, ease: 'power2.in' })
            .add(function () { step.classList.add('is-done'); })
            .to(node, {
                backgroundColor: token('--node-done-bg'),
                borderColor: token('--node-done-line'),
                duration: 0.25 * t
            }, '<')
            .fromTo(chk, { rotationX: 90, opacity: 0 }, { rotationX: 0, opacity: 1, duration: 0.22 * t, ease: 'power2.out' })
            /* The tick draws itself: strokeDashoffset only, no DrawSVG. */
            .fromTo(path, { strokeDashoffset: len }, { strokeDashoffset: 0, duration: 0.34 * t, ease: 'power2.out' }, '<0.04');
    }

    function flipToNumber(step, animate) {
        var node = step.querySelector('.step-node');
        var num = step.querySelector('.step-num');
        var chk = step.querySelector('.step-check');
        var t = animate ? d : 0;

        gsap.timeline()
            .to(chk, { rotationX: 90, opacity: 0, duration: 0.18 * t, ease: 'power2.in' })
            .add(function () {
                step.classList.remove('is-done');
                gsap.set(node, { clearProps: 'backgroundColor,borderColor' });
                armCheck(step);
            })
            .fromTo(num, { rotationX: -90, opacity: 0 }, { rotationX: 0, opacity: 1, duration: 0.22 * t, ease: 'power2.out' });
    }

    function pulse(step) {
        if (reduced) return;
        var node = step.querySelector('.step-node');
        gsap.fromTo(node,
            { scale: 1 },
            { scale: 1.16, duration: 0.22, ease: 'power2.out', yoyo: true, repeat: 1, overwrite: 'auto' });
    }

    function render(animate, pulseActive) {
        steps.forEach(function (step, i) {
            var node = nodes[i];
            var done = i < current;
            var wasDone = step.classList.contains('is-done');
            if (done && !wasDone) flipToCheck(step, animate);
            if (!done && wasDone) flipToNumber(step, animate);
            step.classList.toggle('is-active', i === current);
            /* Only steps already reached are reachable; the rest are disabled
               buttons, so nothing ahead is focusable or clickable. */
            node.disabled = i > current;
            if (i === current) {
                node.setAttribute('aria-current', 'step');
            } else {
                node.removeAttribute('aria-current');
            }
        });

        setFill(current, animate);
        if (pulseActive) pulse(steps[current]);

        if (backBtn) backBtn.disabled = current === 0;
        if (nextBtn) nextBtn.disabled = current === last;
        if (statusEl) {
            var label = steps[current].querySelector('.step-label');
            statusEl.textContent = 'Step ' + (current + 1) + ' of ' + steps.length +
                (label ? ': ' + label.textContent.trim() : '');
        }
    }

    function goTo(index, animate) {
        var next = Math.max(0, Math.min(last, index));
        if (next === current) return;
        current = next;
        render(animate !== false, true);
    }

    nodes.forEach(function (node, i) {
        node.addEventListener('click', function () {
            /* Forward nodes are disabled, so this only ever jumps back. */
            if (i < current) goTo(i);
        });
    });

    if (nextBtn) nextBtn.addEventListener('click', function () { goTo(current + 1); });
    if (backBtn) backBtn.addEventListener('click', function () { goTo(current - 1); });

    window.addEventListener('resize', function () {
        layout();
        setFill(current, false);
    });

    /* Public control: window.stepProgress.goTo(2) from your own form code. */
    window.stepProgress = {
        goTo: goTo,
        next: function () { goTo(current + 1); },
        back: function () { goTo(current - 1); },
        get index() { return current; }
    };

    /* The thumbnail generator would otherwise capture step one of four. */
    window.__thumbnail = function () { goTo(2, false); };

    steps.forEach(function (step) { armCheck(step); });
    layout();
    render(false, false);
    if (document.fonts) {
        document.fonts.ready.then(function () { layout(); setFill(current, false); });
    }
});
})();
!function(){var e;e=function(){if("undefined"!=typeof gsap){var e=document.getElementById("stepper"),t=document.getElementById("stepperRail"),o=document.getElementById("stepperFill"),n=document.getElementById("stepperList"),r=document.getElementById("stepperStatus"),i=document.getElementById("stepBack"),a=document.getElementById("stepNext");if(e&&t&&o&&n){var s=Array.prototype.slice.call(n.querySelectorAll(".step")),c=s.map(function(e){return e.querySelector(".step-node")}),d=s.length-1,u=window.matchMedia("(prefers-reduced-motion: reduce)").matches,p=u?0:1,l=0;c.forEach(function(e,t){e.addEventListener("click",function(){t<l&&x(t)})}),a&&a.addEventListener("click",function(){x(l+1)}),i&&i.addEventListener("click",function(){x(l-1)}),window.addEventListener("resize",function(){y(),w(l,!1)}),window.stepProgress={goTo:x,next:function(){x(l+1)},back:function(){x(l-1)},get index(){return l}},window.__thumbnail=function(){x(2,!1)},s.forEach(function(e){m(e)}),y(),v(!1,!1),document.fonts&&document.fonts.ready.then(function(){y(),w(l,!1)})}}else console.warn("[step-progress-indicator] GSAP is required but was not found.");function f(e){return getComputedStyle(document.body).getPropertyValue(e).trim()}function g(){return window.matchMedia("(max-width: 600px)").matches}function h(e){return e.querySelector(".step-check-path")}function m(e){var t=h(e);if(!t)return 0;var o=t.getTotalLength();return gsap.set(t,{strokeDasharray:o,strokeDashoffset:o}),o}function y(){var o=e.getBoundingClientRect(),n=c[0].getBoundingClientRect(),r=c[d].getBoundingClientRect(),i=parseFloat(f("--track-w"))||3;if(g()){var a=n.top+n.height/2-o.top,s=r.top+r.height/2-o.top;t.style.left=n.left+n.width/2-o.left-i/2+"px",t.style.top=a+"px",t.style.width=i+"px",t.style.height=Math.max(0,s-a)+"px"}else{var u=n.left+n.width/2-o.left,p=r.left+r.width/2-o.left;t.style.left=u+"px",t.style.top=n.top+n.height/2-o.top-i/2+"px",t.style.width=Math.max(0,p-u)+"px",t.style.height=i+"px"}}function w(e,t){var n=d>0?e/d*100:0,r=t?.55*p:0;g()?(gsap.set(o,{width:"100%"}),gsap.to(o,{height:n+"%",duration:r,ease:"power3.inOut"})):(gsap.set(o,{height:"100%"}),gsap.to(o,{width:n+"%",duration:r,ease:"power3.inOut"}))}function v(e,t){if(s.forEach(function(t,o){var n=c[o],r=o<l,i=t.classList.contains("is-done");r&&!i&&function(e,t){var o=e.querySelector(".step-node"),n=e.querySelector(".step-num"),r=e.querySelector(".step-check"),i=h(e),a=m(e),s=t?p:0;gsap.timeline().to(n,{rotationX:-90,opacity:0,duration:.2*s,ease:"power2.in"}).add(function(){e.classList.add("is-done")}).to(o,{backgroundColor:f("--node-done-bg"),borderColor:f("--node-done-line"),duration:.25*s},"<").fromTo(r,{rotationX:90,opacity:0},{rotationX:0,opacity:1,duration:.22*s,ease:"power2.out"}).fromTo(i,{strokeDashoffset:a},{strokeDashoffset:0,duration:.34*s,ease:"power2.out"},"<0.04")}(t,e),!r&&i&&function(e,t){var o=e.querySelector(".step-node"),n=e.querySelector(".step-num"),r=e.querySelector(".step-check"),i=t?p:0;gsap.timeline().to(r,{rotationX:90,opacity:0,duration:.18*i,ease:"power2.in"}).add(function(){e.classList.remove("is-done"),gsap.set(o,{clearProps:"backgroundColor,borderColor"}),m(e)}).fromTo(n,{rotationX:-90,opacity:0},{rotationX:0,opacity:1,duration:.22*i,ease:"power2.out"})}(t,e),t.classList.toggle("is-active",o===l),n.disabled=o>l,o===l?n.setAttribute("aria-current","step"):n.removeAttribute("aria-current")}),w(l,e),t&&function(e){if(!u){var t=e.querySelector(".step-node");gsap.fromTo(t,{scale:1},{scale:1.16,duration:.22,ease:"power2.out",yoyo:!0,repeat:1,overwrite:"auto"})}}(s[l]),i&&(i.disabled=0===l),a&&(a.disabled=l===d),r){var o=s[l].querySelector(".step-label");r.textContent="Step "+(l+1)+" of "+s.length+(o?": "+o.textContent.trim():"")}}function x(e,t){var o=Math.max(0,Math.min(d,e));o!==l&&(l=o,v(!1!==t,!0))}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",e,{once:!0}):e()}();
* { box-sizing: border-box; margin: 0; padding: 0; }
html { overflow-x: clip; }

/* ---------------------------------------------------------------------
   Themes. One design at two token values: `light` and `dark`. Every
   colour, size and radius the component AND the demo ground use is a
   custom property, so re-branding is one block of overrides.
   Set data-variant on <body> in the demo, or on the component's own
   wrapper in your app.

   The script reads --node-done-bg, --node-done-line and --track-w with
   getComputedStyle at the moment they are needed, so a theme swap stays
   correct without a single colour being hard-coded in JS.
   --------------------------------------------------------------------- */
body,
body[data-variant="light"] {
  color-scheme: light;

  /* ground: the demo page the component sits on */
  --ground: #f2f2f1;
  --ground-2: #e9e9e7;
  --raised: #fbfbfa;
  --hover: #e2e2df;
  --ink: #141412;
  --ink-2: #4b4b47;
  --ink-3: #676762;
  --line: rgba(20, 20, 18, 0.12);
  --line-strong: rgba(20, 20, 18, 0.22);
  --shadow: 0 1px 2px rgba(20, 20, 18, 0.06), 0 4px 12px rgba(20, 20, 18, 0.06);
  --font: 'Inter', system-ui, -apple-system, sans-serif;
  --radius: 10px;
  --radius-sm: 6px;

  /* the one accent: the fill, the step you are on, the step you finished */
  --accent: #2563eb;
  --accent-ink: #ffffff;      /* text on the accent */
  --accent-text: #1d4ed8;     /* the accent AS text, on a light surface */
  --accent-wash: #e3ebfb;     /* solid: the accent at ~12% over the ground */

  /* the nodes */
  --node-size: 32px;
  --node-radius: 9999px;
  --node-bg: var(--raised);
  --node-line: var(--line-strong);
  --node-fg: var(--ink-3);
  --node-active-bg: var(--raised);
  --node-active-line: var(--accent);
  --node-active-fg: var(--accent-text);
  --node-done-bg: #2563eb;    /* solid hex: read by the script at flip time */
  --node-done-line: #2563eb;
  --node-done-fg: #ffffff;

  /* the rail */
  --track-w: 2px;
  --track-bg: #dcdcd9;
  --fill-bg: var(--accent);

  /* the labels under the nodes */
  --label-fg: var(--ink-3);
  --label-active-fg: var(--ink);
  --label-done-fg: var(--ink-2);
  --label-weight: 500;
  --label-size: 0.8125rem;
}

body[data-variant="dark"] {
  color-scheme: dark;

  --ground: #141414;
  --ground-2: #0f0f0f;
  --raised: #1c1c1c;
  --hover: #232323;
  --ink: #f1efea;
  --ink-2: #c9c7c1;
  --ink-3: #9a988f;
  --line: rgba(241, 239, 234, 0.12);
  --line-strong: rgba(241, 239, 234, 0.22);
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.4);

  --accent-text: #93b4fd;     /* the accent as text needs lifting on the deep */
  --accent-wash: #1c2540;

  --track-bg: #2e2e2e;
}

body {
  min-height: 100svh;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--font);
  font-size: 0.875rem;
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  padding-top: 44px;                 /* the demo toolbar strip */
}
::selection { background: var(--accent); color: var(--accent-ink); }

/* ---------- Demo furniture: the ground the component is centred on ----- */
.stage { min-height: calc(100svh - 44px); display: grid; place-items: center; padding: 32px 20px; }
.stage__inner { width: 100%; max-width: 720px; display: flex; flex-direction: column; align-items: center; gap: 24px; }

/* ---------- The indicator ---------- */
.stepper {
  position: relative;
  width: 100%;
}
/* The rail is positioned from measurement: it runs from the centre of the
   first node to the centre of the last, so it never pokes out at the ends
   whatever the node size or the gap. */
.stepper-rail {
  position: absolute;
  left: 0;
  top: 0;
  background: var(--track-bg);
  border-radius: 9999px;
  overflow: hidden;
}
.stepper-fill {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 100%;
  background: var(--fill-bg);
  border-radius: inherit;
}
.stepper-list {
  position: relative;
  z-index: 1;
  list-style: none;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
}
.step {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}

/* No CSS transform on the node or on the number/check inside it: GSAP
   pulses the node with scale and flips the two faces on rotationX. */
.step-node {
  position: relative;
  width: var(--node-size);
  height: var(--node-size);
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: var(--node-bg);
  border: 1px solid var(--node-line);
  border-radius: var(--node-radius);
  color: var(--node-fg);
  font: 500 0.8125rem/1 var(--font);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  perspective: 400px;
}
.step-node:disabled { cursor: default; pointer-events: none; }
.step-node:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.step.is-active .step-node {
  background: var(--node-active-bg);
  border-color: var(--node-active-line);
  color: var(--node-active-fg);
  box-shadow: 0 0 0 3px var(--accent-wash);
}
.step.is-done .step-node { border-color: var(--node-done-line); color: var(--node-done-fg); }

.step-num,
.step-check {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: inherit;
  backface-visibility: hidden;
}
.step-check { opacity: 0; }
.step-node-text {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.step-label {
  font-size: var(--label-size);
  font-weight: var(--label-weight);
  color: var(--label-fg);
  white-space: nowrap;
}
.step.is-active .step-label { color: var(--label-active-fg); font-weight: 600; }
.step.is-done .step-label { color: var(--label-done-fg); }

.stepper-status {
  min-height: 1.2em;
  font-size: 0.8125rem;
  color: var(--ink-3);
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* ---------- Compact vertical layout ---------- */
@media (max-width: 600px) {
  /* Shrink to content so the stack centres on the page. */
  .stepper { width: auto; max-width: 100%; }
  .stepper-list {
    flex-direction: column;
    align-items: flex-start;
    justify-content: flex-start;
    gap: 22px;
  }
  .step { flex-direction: row; align-items: center; gap: 12px; }
}

/* ---------- Demo-only controls: the Back / Continue pair ---------- */
.demo-controls { display: flex; gap: 8px; }
.demo-controls button {
  font: 500 0.8125rem/1 var(--font);
  min-height: 36px;
  padding: 0 16px;
  background: var(--raised);
  color: var(--ink);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.demo-controls button:hover:not(:disabled) { background: var(--hover); }
.demo-controls button.is-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}
.demo-controls button.is-primary:hover:not(:disabled) { background: #1d4ed8; border-color: #1d4ed8; }
.demo-controls button:disabled { opacity: 0.45; cursor: default; pointer-events: none; }
.demo-controls button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; }
}

Numbered steps joined by a track whose fill tweens to the active step, with completed nodes flipping their number into a drawn checkmark.

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, every colour and size a plain CSS custom property

  • assets/script.js: readable, commented source with an onReady guard

  • Track fill tweens to the active step in whichever axis the layout is using

  • Completed nodes flip on rotationX from number to a stroke-drawn checkmark

  • The active node pulses once on arrival

  • Click any completed node to jump back; steps ahead are disabled

  • Rail measured from first node centre to last, so any node size works

  • Compact vertical layout under 600px, remeasured on resize

  • window.stepProgress.goTo / next / back for your own form code

Quick Start

1. Add to your HTML <head>:

<link rel="stylesheet" href="assets/style.css">

2. Add to your <body>:

Copy the <nav class="stepper"> block and the <p class="stepper-status"> line out of index.html (everything else on that page is demo furniture). Add or remove <li class="step"> items freely: the script counts them, and the rail and the fill percentages follow. The ids it needs are stepper, stepperRail, stepperFill, stepperList and stepperStatus.

3. Add before the closing </body> tag:

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="assets/script.js"></script>

4. Drive it from your form:

// after the step-one fields validate
window.stepProgress.next();

// or jump straight to a step, zero-based
window.stepProgress.goTo(2);

// current index
console.log(window.stepProgress.index);

The demo's Continue and Back buttons are only wired to those same calls, so delete them once your own form owns the flow.

How It Works

A measured rail, not a padded one. The rail is positioned from the centre of the first node to the centre of the last with getBoundingClientRect, so it never pokes past the end nodes whatever their size, gap or label width. It is remeasured on load, after web fonts land, on resize and whenever the theme changes.

One fill, two axes. The fill is a child of the rail. Horizontally GSAP tweens its width to a percentage of the step index; below 600px the same call tweens height instead, and the unused axis is pinned to 100% so rotating the layout never leaves a stale dimension behind.

Flipping a number into a check. The number and the checkmark sit stacked inside the node with backface-visibility: hidden and a perspective on the parent. Completing a step rotates the number out on rotationX, rotates the check in, and tweens the check path's strokeDashoffset from its measured length to zero so the tick draws itself. No DrawSVG plugin. Jumping back plays the same flip in reverse and clears the inline colours GSAP set.

Reachable means focusable. Every node is a real <button>. Steps ahead of the current one carry disabled plus pointer-events: none, so they are skipped by keyboard navigation and cannot swallow a click; completed steps stay live and jump the flow back.

Themes

The indicator ships in two themes, light (the default) and dark: one design at two token values. Pick one with an attribute:

<body data-variant="dark">

In your own app the attribute can sit on any wrapper around the component instead of on <body>; nothing in the script ever reads it.

Both themes define the same short list of custom properties in assets/style.css, so re-valuing them for your own brand is one block:

Group Properties
Ground (demo only) --ground, --ground-2, --raised, --hover, --line, --line-strong, --shadow
Ink --ink, --ink-2, --ink-3
Accent --accent, --accent-ink, --accent-text, --accent-wash
Nodes --node-size, --node-radius, --node-bg, --node-line, --node-fg, --node-active-bg, --node-active-line, --node-active-fg, --node-done-bg, --node-done-line, --node-done-fg
Rail --track-w, --track-bg, --fill-bg
Labels --label-fg, --label-active-fg, --label-done-fg, --label-weight, --label-size

Point --accent at your own brand colour and the fill, the active node, the completed nodes and the focus ring all follow. Keep --node-done-bg and --node-done-line as solid colour values: the script reads them with getComputedStyle at the moment the flip tween starts, so a theme swap is correct on the next move without a colour ever being hard-coded in JS.

Demo furniture. The toolbar strip at the top of index.html, the .stage / .stage__inner wrapper that centres the component on the grey ground, and the Back / Continue pair in .demo-controls are the demo, not the component. Delete them; the component is the <nav class="stepper"> block, the .stepper-status line and assets/script.js.

Customisation

Property Where Default (light) Description
--node-size style.css 32px Node diameter; the rail re-measures itself around it
--node-radius style.css 9999px Node corner radius; set it to 6px for square nodes
--node-done-bg style.css #2563eb Fill a node tweens to when it completes
--node-active-line style.css var(--accent) Border of the step you are on
--track-w style.css 2px Rail thickness, in both orientations
--fill-bg style.css var(--accent) The progress fill colour
--label-* style.css see block Label size, weight and colour per state
VERTICAL_QUERY script.js (max-width: 600px) Where the layout stacks; keep it in step with the CSS media query

Easings and durations are the gsap.to / gsap.fromTo calls in assets/script.js. The checkmark shape is the d attribute of .step-check-path in index.html; change it and the draw still works, because each path's length is measured at runtime.

Accessibility

  • Every node is a real <button>, so the whole indicator is keyboard operable with Tab and Enter; nothing relies on hover.
  • Steps ahead of the current one are disabled, which keeps them out of the tab order, and .step-node:disabled sets pointer-events: none so a disabled node never absorbs a press.
  • The active node carries aria-current="step", and a role="status" line announces "Step 2 of 5: Bank account" on every change.
  • Each node has a visually hidden full name ("Step 2, Bank account"), so the visible label under it is aria-hidden and nothing is read twice.
  • Under prefers-reduced-motion: reduce every duration collapses to zero and the pulse is skipped: the states still change in order, they just do not animate.
  • Focus is visible on the nodes and the demo controls via :focus-visible outlines.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework