Front-End solution: progress indicator

All is revealed about how to build this Front-End Challenges Club challenge, with some great contributions to the community too.


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 Andy Bell (@piccalilli) on CodePen.

Starting with markup permalink

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 component permalink

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:

  1. aria-label allows us to provide a descriptive label, which is populated via a property of the web component
  2. The progressbar role gives the right type of information to assistive tech
  3. The aria-valuemax works alongside the progressbar 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 progress permalink

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 attributes permalink

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 up permalink

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:

  1. container-type is being set to inline-size because we’re going to be using container units to size the text and icon
  2. 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 Andy Bell (@piccalilli) on CodePen.

Wrapping up permalink

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 🙌