Solution: Progress Stepper

Front-End Challenges Club - Challenge #008

This is the solution for Challenge #008.

You’d be forgiven for thinking that this challenge is going to require a lot of HTML elements to get it working, because when you scratch under the surface, there’s a lot going on and plenty to consider. Those of you that are not new to Front-End Challenges Club will be all too aware of this theme by now.

Luckily, we can actually build this challenge with a pretty darn slim and semantic HTML base. The CSS does admittedly take the brunt of the work, though, but let’s dive in and learn some cool stuff regardless.

This solution has the following code files:

  • index.html
  • css/global.css

You can see a live demo or download a complete version of what we’re making in this solution, here.

HTML permalink

As always, let’s start with a nice HTML shell. Open up your index.html file and add the following:

Code language
<!DOCTYPE html>
<html lang="en">
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Front-End Challenges Club #008</title>
    <link rel="stylesheet" href="/css/global.css" />

This is our core structure and as you can see, it’s pretty simple. We’re pulling in our CSS, along with a CDN version of my modern reset that I use. We’re also pulling in some fonts directly from Google Fonts.

Now we have our HTML shell, let’s add our main stepper component markup. We’ll add it all in one chunk and go through it afterwards. Open up index.html and add the following to it, between the opening and closing <body> tags:

Code language
<div class="stepper">
  <ol role="list" aria-label="Purchase steps">
    <li data-status="complete">
      <span aria-hidden="true">Step one</span>
      <strong>Your basket</strong>
    <li data-status="complete">
      <span aria-hidden="true">Step two</span>
      <strong>Your details</strong>
    <li aria-current="step">
      <span aria-hidden="true">Step three</span>
      <span aria-hidden="true">Step four</span>
      <strong>Order complete</strong>

Is that it?? Hell yeh it is. Let’s break it down:

  1. We use an ordered list, because this stepper has specifically ordered content
  2. We add an aria-label to describe what this list is. We only do this because of what we implement in the next point, so it’s helpful to provide some more context to screen readers.
  3. Inside each list, we hide Step X from screen readers with aria-hidden="true". Because we use an <ol>, we get that numerical announcement already. The Step X bits are purely for providing visual sugar
  4. We’re using aria-current="step" which tells assistive technology, such as screen readers that this item is the current step. We’re using data-status to hook onto finite state in our CSS too, which we will get into more detail with later on

That’s our lot for HTML. Importantly, if no CSS manages to load, this list will work perfectly well as our stepper. Good ol’ progressive enhancement in action.

CSS permalink

Let’s start off by extending the root custom properties that I provided in the challenge post.

Add the following to css/global.css:

Code language
:root {
  --color-light: #fdfdfd;
  --color-dark: #27474e;
  --color-mid: #496970;
  --color-primary: #f3344a;
  --color-primary-glare: #f4d7da;
  --color-secondary: #678c94;
  --color-secondary-glare: #ebf0f1;
  --font-condensed: 'Saira Condensed', sans-serif;
  --font-sans: 'Asap', sans-serif;
  --shadow: 0px 0px 40px rgba(39, 71, 78, 0.1);
  --size-300: 0.88rem;
  --size-400: 1rem;
  --size-500: 1.44rem;
  --size-600: 2rem;
  --size-700: 2.5rem;

The colours and treatments are the same. All I added here is some fonts and a low-key size scale for keeping things consistent.

Let’s add the following global styles to our CSS next:

Code language
body {
  font-family: sans-serif;
  background: var(--color-light);
  color: var(--color-dark);
  display: grid;
  place-items: center;

There’s not much to talk about here as it’s all straightforward. We’re using the ol’ place-items CSS Grid trick purely for demo purposes to frame the finished stepper nicely.

Now, let’s start on the stepper component itself. Add the following to your CSS:

Code language
.stepper {
  --stepper-y-space: var(--size-500);
  --stepper-x-space: var(--size-700);
  --stepper-modifier: 1.5ex;

  border: 1px solid var(--color-secondary-glare);
  padding: var(--size-600);
  box-shadow: var(--shadow);
  border-radius: var(--size-400);
  min-width: 20rem;
  counter-reset: steps;

The first thing to note is that we are setting some specific custom properties for this component. These will make spacing and positioning nice and consistent, without having magic numbers in our calculations.

The --stepper-modifier is a handy property that we are going to use for more fine-grained control. We use an ex unit, which is the height of the x character (x-height) in your chosen font and size. This helps us position things flush with our text with a unit that’s designed for exactly that.

Lastly, we’re using CSS counters in this solution. We’re resetting the steps counter which means that each instance of .stepper on a page starts at 1.

Next up: list styles. Add the following to your CSS:

Code language
.stepper [role='list'] {
  font-family: var(--font-condensed);
  line-height: 1.1;
  text-transform: uppercase;
  margin: calc(var(--stepper-y-space) * -1) 0 0 0;
  padding: 0;
  list-style: none;

.stepper li {
  padding-left: var(--stepper-x-space);
  padding-top: var(--stepper-y-space);
  position: relative;
  counter-increment: steps;

You might notice that we are using [role="list"] as a selector. Something I was made aware of recently is that if you remove list styles from a list, VoiceOver will sometimes not announce it as a list, which is…well…incredibly frustrating. We could select the <ol> directly, but this approach gives us future flexibility to use a <ul> too.

The actual li elements get padded up to create space and they increment the steps counter.

You might have noticed that we add padding to every li element, including the first one. The even more eagle-eyed amongst us might have also noticed that we set a negative top margin on the [role="list"]. This helps us with absolutely positioned elements because the edges of the relative parent are X pixels from the visual edge.

Did you know, you can make a CSS Custom Property negative by multiplying it by -1?

For example, if you set --my-var to 1, and --my-negative-var to calc(var(--my-var) * -1): --my-negative-var will be -1.

Let’s add some vertical lines to our stepper. Add the following to your CSS:

Code language
.stepper li::before,
.stepper li::after {
  display: none;
  content: '';
  width: 2px;
  background: var(--color-primary);
  position: absolute;
  left: 7px;

/* Up line */
.stepper li::before {
  height: calc(var(--stepper-y-space) + var(--stepper-modifier));
  top: 0;

/* Down line */
.stepper li::after {
  height: 100%;
  top: calc(var(--stepper-y-space) + var(--stepper-modifier));

The first thing we do here is set each line to visually look the same: red and 2px wide. Then, we hide them both by default because it is state that will determine their visibility.

The line is split in two parts; the ::before pseudo-element is our up line and the ::after pseudo-element is our down line. Because each li element has top space of --stepper-y-space, we make the up line have the same value in height and add that --stepper-modifier, which is 1.5ex to account for the small amount of vertical space the font creates.

The down line uses similar logic but is 100% high. This makes it “leak” into the following li element. It’s all smoke and mirrors in here today, folks.

Now, we need to toggle their visibility. Add the following to your CSS:

Code language
.stepper li[aria-current='step']::before {
  display: block;

.stepper li[data-status='complete']::after,
.stepper li[data-status='complete']::before {
  display: block;

/* Always hide the top up line and the bottom down line */
.stepper li:first-child::before,
.stepper li:last-child::after {
  display: none;

Firstly, the current item’s up line is shown. This is subsequently reset in the last block because if the first item is also the current item, there is not preceding step to link to, visually.

After that, we use an Exception to link items together when they are complete. If an item has a data-status='complete' attribute, then both the up and down lines are shown.

Right, that’s the lines done, so let’s add our dots. Add the following to your CSS:

Code language
.stepper strong {
  display: block;
  font-family: var(--font-sans);
  font-size: var(--size-500);
  text-transform: none;
  position: relative;

/* Dot */
.stepper strong::after {
  content: '';
  display: block;
  width: 16px;
  height: 16px;
  border-radius: 16px;
  background-color: var(--color-secondary-glare);
  position: absolute;
  bottom: 100%;
  left: calc(var(--stepper-x-space) * -1);
  border: 1px solid var(--color-secondary);
  transform: translateY(50%);
  z-index: 1;

The first thing we are doing here is setting the <strong> in our stepper to be a block, which breaks it on to a new line. It’s also helpful to set it as a block because we make it a relative parent for both the decorative dot and the counter increment.

The dot is built up using another pseudo-element and for this, I am using pixels because I want exact sizing. We then push it out to the left using a negative version of the --stepper-x-space custom property. Lastly, using the magic of transforms, we can sit the dot in the center by first, setting bottom: 100% followed by translateY(50%) which pushes it back down, 50% of its height (8px in this instance).

Lastly, we want the dot to sit on top of the lines, so all we need to do for that is set z-index to 1. This is because so far, nothing else in this stacking context is set.

Let’s add our decorative counter next. This is the “faded” number that sits behind each step. Add the following to your CSS:

Code language
.stepper strong::before {
  content: counter(steps, decimal-leading-zero);
  speak-as: numbers;
  font-family: var(--font-condensed);
  font-weight: 900;
  color: var(--color-secondary-glare);
  position: absolute;
  bottom: 2ex;
  left: -1ch;
  z-index: -1;
  line-height: 1;

Some cool stuff happening here, so let’s break it down:

  1. First up: we finally use that counter by setting the pseudo-element’s content as counter(steps, decimal-leading-zero). That second property does what it says on the tin: adds a leading zero
  2. Because we add a leading zero, I want to be double sure that this is announced as numbers, if it is announced by a screen reader. In Firefox, a speak-as property is supported, so we add numbers as the value
  3. After the usual type settings, we have a magic trick. Because the number has a negative indent, we can use a really handy property to set that. By setting left as -1ch, we are shifting it left by negative the width of the 0 character, which is especially handy in this context

We’re getting close to the end now. Let’s work with some finite state. Add the following to your CSS:

Code language
.stepper [aria-current='step'] strong::after {
  background-color: var(--color-primary-glare);
  border-color: var(--color-primary);

Our active state gives us a useful, finite hook to work with. Ideally, whatever is powering this stepper will only set aria-current="step" on the current item. With this in mind, we can reasonably comfortably set some different colours to highlight it.

Next, add the following to your CSS:

Code language
.stepper [data-status='complete'] strong::after {
  background-color: var(--color-primary);
  background-image: url('data:image/svg+xml;utf8,<svg fill="white" width="9" height="7" xmlns=""><path d="M7.868.141a.474.474 0 01.03.652L3.244 6.087a.433.433 0 01-.627.028L.292 3.911a.474.474 0 01-.03-.652.433.433 0 01.628-.028l1.996 1.892L7.24.17A.433.433 0 017.868.14z"/></svg>');
  background-size: 9px 7px;
  background-position: 3px center;
  background-repeat: no-repeat;
  border-color: var(--color-primary);

Ok, this is fun. This is the “ticked” (or “checked”) state. We can use a smart CSS trick for this. You can use inline SVG in your CSS by setting a background image and prefixing the SVG code with data:image/svg+xml;utf8,, which essentially lets CSS understand that is SVG that should be treated as an image.

Because the dot is sized with pixels, we can use pixels to position the tick for us. This is mainly because a tick will never look central, even if it is central. We fix this using an optical adjustment, which is a nice term for “designer’s curse”, which I prefer to call it.

With all of that now set, we are done! Go ahead and load it up in your browser to enjoy your hard work.

Wrapping up permalink

I really hope you’ve enjoyed this tutorial. You can grab a zip of the final code that I wrote and also see a live version too. There’s even a git repository.

My favourite attempt for this challenge was by Geoff, who also did a fantastic, detailed write-up. Nice work, Geoff!

It’s great to be back with these challenges. Stay tuned for more soon!

Until the next time, take it easy 👋