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.
HTMLpermalink
As always, let’s start with a nice HTML shell. Open up your index.html
file and add the following:
- Code language
- html
<!DOCTYPE html> <html lang="en"> <head> <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="https://cdn.jsdelivr.net/npm/modern-css-reset/dist/reset.min.css" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Asap:wght@700&family=Saira+Condensed:wght@400;900&display=swap" /> <link rel="stylesheet" href="/css/global.css" /> </head> <body></body> </html>
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
- html
<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> <li data-status="complete"> <span aria-hidden="true">Step two</span> <strong>Your details</strong> </li> <li aria-current="step"> <span aria-hidden="true">Step three</span> <strong>Payment</strong> </li> <li> <span aria-hidden="true">Step four</span> <strong>Order complete</strong> </li> </ol> </div>
Is that it?? Hell yeh it is. Let’s break it down:
- We use an ordered list, because this stepper has specifically ordered content
- 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. - Inside each list, we hide
Step X
from screen readers witharia-hidden="true"
. Because we use an<ol>
, we get that numerical announcement already. TheStep X
bits are purely for providing visual sugar - We’re using
aria-current="step"
which tells assistive technology, such as screen readers that this item is the current step. We’re usingdata-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.
CSSpermalink
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
- css
: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
- css
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
- css
.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
- css
.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
- css
.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
- css
.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
- css
.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
- css
.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:
- First up: we finally use that counter by setting the pseudo-element’s
content
ascounter(steps, decimal-leading-zero)
. That second property does what it says on the tin: adds a leading zero - 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 addnumbers
as the value - 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 the0
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
- css
.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
- css
.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="http://www.w3.org/2000/svg"><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 uppermalink
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 👋