This is the solution to Front-End Challenge #009, so check that out first if you haven’t already.
I knew when I set this challenge, there were multiple ways to solve it and I was not disappointed by the diversity in approaches that folks kindly sent to me.
Here’s how I did it.
See the Pen The finished demo by piccalilli (@piccalilli) on CodePen.
Starting with markuppermalink
I’ve opted for a little web component that progressively enhances a loading statement, so most of the markup lives in that component. But, it’s important that the default experience is acceptable and informative.
- Code language
- html
<progress-indicator progress="0" stroke="8" viewbox="130"> <div role="alert" aria-live="polite"> <p>Loading, please wait…</p> </div> </progress-indicator>
We’re calling on a <progress-indicator>
custom element and inside that, there’s some default markup. It’s a statement that lets the user know that something is loading. Using the role="alert"
attribute along with aria-live="polite"
gives a screen reader the right message too without interrupting their flow.
The web componentpermalink
The first thing to do is create the component and add it to the registered custom elements:
- Code language
- js
class ProgressIndicator extends HTMLElement { } customElements.define("progress-indicator", ProgressIndicator);
What’s going on here is we’re creating a class that extends the base HTMLElement
object and then assigning that to any <progress-indicator>
element that is found on a page.
Next, let’s add our constructor method. This method runs as soon as the element is registered. I use constructor
instead of connectedCallback()
because I want to replace that default HTML that we added earlier as quickly as possible.
- Code language
- js
constructor() { super(); // Calculate the circle radius and the normalised version which is radius minus the stroke width const radius = this.viewBox / 2; const normalisedRadius = radius - this.stroke; this.calculatedCircumference = normalisedRadius * 2 * Math.PI; // Set the custom property viewbox value for our CSS to latch on to this.style.setProperty("--progress-indicator-viewbox", `${this.viewBox}px`); // Set the default aria role states this.setAttribute("aria-label", this.label); this.setAttribute("role", "progressbar"); this.setAttribute("aria-valuemax", "100"); // Render the component with all the data ready this.innerHTML = ` <div class="progress-indicator"> <div class="progress-indicator__visual"> <div data-progress-count class="progress-indicator__count"></div> <svg fill="none" viewBox="0 0 ${this.viewBox} ${this.viewBox}" width="${this.viewBox}" height="${this.viewBox}" focusable="false" class="progress-indicator__circle" > <circle r="${normalisedRadius}" cx="${radius}" cy="${radius}" stroke-width="${this.stroke}" class="progress-indicator__background-circle" /> <circle r="${normalisedRadius}" cx="${radius}" cy="${radius}" stroke-dasharray="${this.calculatedCircumference} ${this.calculatedCircumference}" stroke-width="${this.stroke}" class="progress-indicator__progress-circle" data-progress-circle /> </svg> <svg class="progress-indicator__check" focusable="false" viewBox="0 0 20 20" fill="none" > <path d="m8.335 12.643 7.66-7.66 1.179 1.178L8.334 15 3.032 9.697 4.21 8.518l4.125 4.125Z" fill="currentColor"/> </svg> </div> </div> `; }
Let’s break down what’s going on here. First up, we’re running super();
. This instructs the parent class — HTMLElement
— to construct. This is required.
The first thing to do is calculate a radius
, which is the viewBox
property, halved. We get that data later in the component code.
The normalisedRadius
is that radius
value, minus the width of the stroke — also covered later in the component. This means the circles wont exceed the bounds of the viewBox
.
The next bit is where the heat increases a touch — especially for a professional rectangle drawer that happens to code, in my case 😅. For displaying the progress, visually, we need to calculate the circumference of the circle using PI — y’know, from maths class at school. Unfortunately, I did not like maths at school, so I can’t really teach you how it works. What I can do though is tell you we calculate that circumference by doubling the normalised radius, then multiplying that by PI.
Next up, it’s time to set up some attributes on our <progress-indicator>
component, and we start by setting a CSS Custom Property — --progress-indicator-viewbox
— which we’ll hook on to later in our CSS. Following that, we need to make sure this isn’t a exclusionary, visual-only component, so we’ve got some aria roles to set:
aria-label
allows us to provide a descriptive label, which is populated via a property of the web component- The
progressbar
role gives the right type of information to assistive tech - The
aria-valuemax
works alongside theprogressbar
role to let an assistive tech user know what the max progress value will be
Last up in the constructor, we’re populating a template literal by stitching markup with all of the data we gathered and calculated. A lot of it is self explanatory, but I want to point out the bit that shows progress.
Now, stand back as I attempt to explain the stroke-dasharray
attribute…
The idea is to add a series of numbers like this: stroke-dasharray="10 5"
. These numbers specify the length of alternating dashes and gaps between those dashes. So, for our example, the dashes will be 10 units long with a gap of 5 units. The units represent the length of the stroke, which in our case is the circumference of the circle.
We set our attribute as the following: stroke-dasharray="${this.calculatedCircumference} ${this.calculatedCircumference}"
. This means each dash is the circumference of our circle and so is each gap. This allows us to reveal portions of this using stroke-dashoffset
. We do that in the next part.
Monitoring and displaying progresspermalink
The core of our component is in, so now let’s add a method that updates the progress state, both visually and auditorily.
- Code language
- js
setProgress(percent) { // Always make sure the percentage passed never exceeds the max if (percent > 100) { percent = 100; } // Set the aria role value for screen readers this.setAttribute("aria-valuenow", percent); const circle = this.querySelector("[data-progress-circle]"); const progressCount = this.querySelector("[data-progress-count]"); // Calculate a dash offset value based on the calculated circumference and the current percentage circle.style.strokeDashoffset = this.calculatedCircumference - (percent / 100) * this.calculatedCircumference; // A human readable version for the text label progressCount.innerText = `${percent}%`; // Set a complete or pending state based on progress if (percent == 100) { this.setAttribute("data-progress-state", "complete"); } else { this.setAttribute("data-progress-state", "pending"); } }
What’s happening here in a nutshell is we’re first checking that the progress doesn’t exceed 100%. If that’s all good, we move on to setting the aria-valuenow
value to the current percentage value.
After grabbing the correct circle and text label elements, we populate the number value on the text label and then, the dash array magic happens. By multiplying the circumference by the point value of the percentage, we get a dash offset value that reveals only the right amount of stroke to visually show progress on the indicator.
If they had shown me stuff like this in school, I might have actually paid attention in maths lessons…
Lastly, we set some data attributes that we can hook on to CSS. Using the CUBE exception approach, we’re setting either a progress or complete state on the data-progress-state
attribute.
Getters and observed attributespermalink
Right, these are the last few parts of our web component before we can move on to the fun part: CSS.
The first thing to do is observe the progress
attribute on our custom element with the following snippet:
- Code language
- js
static get observedAttributes() { return ["progress"]; }
This is a property that’s available on all web components and it does exactly what it says on the tin. You can observe as many attributes as you like. All you’ve got to do is pop them in the return array.
Let’s hook on to those attribute changes now.
- Code language
- js
attributeChangedCallback(name, oldValue, newValue) { if (name === "progress") { this.setProgress(newValue); } }
It’s pretty straightforward, really. We’re checking if the progress attribute is the one that triggered this lifecycle callback then popping the new value in our setProgress
method. Now, any outside JS can update this progress indicator by changing the progress
attribute value.
This is the last part of the JS now, we need to set our getters for the various bits of data we need from the component’s attributes:
- Code language
- js
get viewBox() { return this.getAttribute("viewbox") || 100; } get stroke() { return this.getAttribute("stroke") || 5; } get label() { return this.getAttribute("label") || "Current progress"; }
All we’re doing here is attempting to grab data from those attributes and if there’s nothing, setting some sensible defaults. Lovely.
Styling it uppermalink
Finally, it’s time to get into some CSS. First up, I’ve modified and added to the Custom Properties I provided in the challenge:
- Code language
- css
:root { --font-base: "Space Mono", monospace; --transition: 200ms linear; --color-dark: #1f1a38; --color-dark-glare: #989ea9; --color-success: #76f7bf; --progress-indicator-color-complete: var(--color-success); --progress-indicator-progress-stroke: var(--color-dark); --progress-indicator-bg-stroke: var(--color-dark-glare);
All I’ve done is add some more specific theming properties that hook on to the more constant design tokens.
- Code language
- css
.progress-indicator { font-family: var(--font-base); line-height: 1.1; color: var(--color-dark); container-type: inline-size; width: var(--progress-indicator-viewbox); height: auto; }
This is the start of our component (block) styles. The two bits I want to touch on are the following:
container-type
is being set toinline-size
because we’re going to be using container units to size the text and icon- The
--progress-indicator-viewbox
property — if you remember — is set by our web component’s code. This is where our CSS hooks on to it by setting the width
- Code language
- css
.progress-indicator__progress-circle { stroke: var(--progress-indicator-progress-stroke, currentColor); transition: stroke-dashoffset var(--transition); transform: rotate(-90deg); transform-origin: 50% 50%; }
This is the circle that holds our progress indicator stroke. The important part to touch on here is that the dash array starts from 90 degrees on our circle, so we need to transform it back to the top by rotating -90deg
. The transform origin is from the center too.
- Code language
- css
.progress-indicator__background-circle { stroke: var(--progress-indicator-bg-stroke, grey); }
This part is pretty straightforward. If there’s a Custom Property value for the background circle’s stroke — the grey one — we use that. Otherwise, we default to the grey
(or gray
if you’re American) system colour.
- Code language
- css
.progress-indicator__check { width: var(--progress-indicator-check-size, 60cqw); height: auto; display: none; }
This part is our little check icon which shows up when progress
reaches 100%. The reason we set the .progress-indicator
to be a container earlier is because we’re using cqw
units to size the checkbox, relative to the component’s width. Pretty handy, right?
- Code language
- css
.progress-indicator__count { font-size: var(--progress-indicator-count-size, max(25cqw, 1rem)); z-index: 1; }
Sticking with that theme, we’re doing the same for the text too. The difference in our fallback value is we’re making sure the text is at least 1rem
with max()
. Please make sure when you’re using container or viewport units to size text that it doesn’t fail the WCAG 1.4.4 guideline: Resize text.
Right, we need all the inner parts of the indicator to stack on top of each other nicely. Let me teach you a CSS grid party trick:
- Code language
- css
.progress-indicator__visual { display: grid; grid-template-areas: "stack"; align-items: center; place-items: center; } .progress-indicator__visual > * { grid-area: stack; }
The .progress-indicator__visual
element houses both the text label and the icon, so what’s happening here is we’ve got a grid layout with one named area: stack
. By putting both icon and label in the same area, they stack on top of each other. We don’t need to worry about z-index because they’re never shown together. Cool right?
Finally, let’s add some state exceptions to wrap this thing up. Remember us modifying data-progress-state
in our JS code? We’re going to respond to those changes now:
- Code language
- css
[data-progress-state="complete"] .progress-indicator__progress-circle { fill: var(--progress-indicator-color-complete); } [data-progress-state="complete"] .progress-indicator__count { display: none; } [data-progress-state="complete"] .progress-indicator__check { display: revert; }
The first rule changes the fill colour of our circle to the complete colour. There’s no fallback here because the icon is demonstrating completeness too.
The second rule hides the text label, followed by the third rule which reverts the display state of the icon, allowing its default to shine through, showing it visually.
And with all of that done, that’s a wrap!
See the Pen The finished demo by piccalilli (@piccalilli) on CodePen.
Wrapping uppermalink
I was blown away by how many of you attempted this challenge. I honestly thought no one would be interested in Front-End Challenges Club with it being idle for years at this point, but I’m glad I was completely wrong in that assumption.
My favourite attempt was by Noah, because they also shared some good notes about their build too. I’m a sucker for a blog post!
I deliberately didn’t add a rounded line cap to the indicator’s stroke because I wanted to free up people to use more CSS-based approaches. Props to Noah and folks like Mayank who really pushed the boat out in that sense.
Finally, Amit and Kevin both recorded videos of their solutions. I love to see that and you should also check them out. There’s many ways to build this component and I’m really happy that the front-end community stepped up to demonstrate that 🙌