v138 · css

Progress Steps

Two step-progress patterns — a horizontal step tracker and a vertical job pipeline — where both the visible step number and the screen-reader announcement are driven by counter(). Visual: "3". Spoken: "Step 3 — current step". Zero JS, zero hard-coded numbers.

Feature detection: checking…
Horizontal step tracker — counter() drives visual number & alt text Step 3 active
  1. Applied
  2. Screened
  3. Interview
  4. Offer
  5. Hired
Accessibility Object Model — what a screen reader announces for each step circle
step 1:visual "1"→ "Step 1 — done"
step 2:visual "2"→ "Step 2 — done"
step 3:visual "3"→ "Step 3 — current step"
step 4:visual "4"→ "Step 4 — not yet started"
step 5:visual "5"→ "Step 5 — not yet started"
Vertical pipeline — counters() supplies "Stage N of 5" in alt text
  1. Application received
    Resume and cover letter reviewed by recruiting team.
    Passed
  2. Phone screen
    30-minute call with a recruiter to confirm background and expectations.
    Passed
  3. Technical interview
    3-hour session with engineers. Live coding and architecture discussion.
    Scheduled
  4. Hiring committee review
    Panel decision based on all interview feedback.
    Upcoming
  5. Offer extended
    Compensation package sent; candidate has 5 business days to respond.
    Upcoming

Pre-138 vs Chrome 138+

Pre-Chrome 138

counter() was silently ignored inside the alt-text half of the content property. Authors had to duplicate the count manually in ARIA labels (aria-label="Step 3 of 5") or use JavaScript to keep visual counter and ARIA in sync. If steps were added or removed, the JS had to be updated too.

Chrome 138+

The alt-text half of content: visual / alt now evaluates counter() and counters() at render time — the same counter value used for the visual label is reused verbatim in the accessible name. Add or remove steps; both the number and the announcement update automatically.

/* counter-reset on the list */
ol.steps {
  counter-reset: step;
}

/* Each li increments and uses counter() in BOTH halves */
li.step::before {
  counter-increment: step;

  /* "3" visually, "Step 3 — current step" for screen readers */
  content: counter(step) / "Step " counter(step) " — current step";

  /* Chrome 138+: counter() is evaluated in the alt-text slot */
}

/* Different states get different alt-text phrasing */
li.step.done::before {
  content: counter(step) / "Step " counter(step) " — done";
}

li.step.pending::before {
  content: counter(step) / "Step " counter(step) " — not yet started";
}

see also