Solution: Progress Button

Categories

Front-End Challenges Club - Solution #004


This is the solution for Challenge #004 and it’s our first challenge using a JavaScript framework. We’re using React today.

Because of this, the base files are a little more complex, so today, I’ve given you a starter project. Before you do anything else, download the starter project here and move your terminal to the solution-004-base directory inside there. Once you’ve done that, run npm install.

Inside the src directory, you can see we have an index.html page, a css directory which contains a components directory, and a js directory which also contains a components directory. Because the majority of this solution is React-based, let’s start with that.


⚠️ Note: For the rest of this tutorial, we will be working inside the src directory, so the rest of the paths below will presume you’re already in there.


JavaScript permalink

Inside the js directory, create a file called App.js with the following code:

Code language
js
import React from 'react';

import ProgressButton from './components/ProgressButton.js';

const App = () => (
  <ProgressButton defaultText="Submit" errorText="Error" successText="Success" />
);

export default App;

What we’re doing here is creating a shell for our main.js to pull in and render to the DOM. It might seem a little bit like an abstraction too far, but this is a personal preference of mine to keep the main React code grouped together.

Speaking of main.js, let’s edit that file. Open it up and add the following:

Code language
js
import React from 'react';
import {render} from 'react-dom';
import App from './App.js';

render(<App />, document.querySelector('[data-app="main"]'));

Again, pretty straightforward. It’s pulling our app and using React DOM to insert our React app inside the [data-app="main"] element which already exists in index.html.

We’ve got our core JavaScript structure now, so let’s create some components. Before we get to the juicy stuff, let’s create a run-of-the-mill icon handler. Because we’ve got a couple of icons to work with, I’m going to create a state-driven component that builds an <svg> on the fly. Inside js/components, create a file called Icon.js and put the following code in there:

Code language
js
import React from 'react';
import PropTypes from 'prop-types';

const paths = {
  success:
    'M21.7237 3.42804L7.99978 18.4715L2.27582 12.1972C1.75449 11.6257 0.910494 11.6257 0.390497 12.1972C-0.129499 12.7686 -0.130832 13.6938 0.390497 14.2638L7.05712 21.5714C7.57845 22.1429 8.42244 22.1429 8.94244 21.5714L23.609 5.49464C24.1303 4.92318 24.1303 3.99804 23.609 3.42804C23.0877 2.85805 22.2437 2.85659 21.7237 3.42804V3.42804Z',
  error:
    'M0.502696 2.92661L9.57609 12L0.502696 21.0734C-0.167565 21.7437 -0.167565 22.8288 0.502696 23.4973C1.17296 24.1659 2.25806 24.1676 2.92661 23.4973L12 14.4239L21.0734 23.4973C21.7437 24.1676 22.8288 24.1676 23.4973 23.4973C24.1659 22.827 24.1676 21.7419 23.4973 21.0734L14.4239 12L23.4973 2.92661C24.1676 2.25635 24.1676 1.17124 23.4973 0.502696C22.827 -0.165851 21.7419 -0.167565 21.0734 0.502696L12 9.57609L2.92661 0.502696C2.25635 -0.167565 1.17124 -0.167565 0.502696 0.502696C-0.165851 1.17296 -0.167565 2.25806 0.502696 2.92661Z',
  default:
    'M7.92669 23.4979L18.2123 13.2123C18.8826 12.542 18.8826 11.4569 18.2123 10.7883L7.92669 0.50271C7.25641 -0.16757 6.17128 -0.16757 5.50271 0.50271C4.83414 1.17299 4.83243 2.25812 5.50271 2.92669L14.5763 12.0003L5.50271 21.074C4.83243 21.7442 4.83243 22.8294 5.50271 23.4979C6.17299 24.1665 7.25812 24.1682 7.92669 23.4979V23.4979Z'
};

As with all React components, we import React, but in this one, we also import PropTypes which helps to make sure the props that we pass into the component are the correct data type.

After that, we have an object which defines the path for each icon variant. This is a handy trick because most of the <svg> is identical each time, with only the d attribute of the <path> changing. Because of this, I can slice up the icon assets and make only the changing elements dynamic. Consider this Andy’s tip of the day!

Still inside Icon.js, add the following code:

Code language
js
const Icon = ({pathKey = 'default'}) => {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      aria-hidden="false"
      focusable="false"
      width="1em"
      height="1em"
      fill="currentColor"
    >
      <path d={paths[pathKey]}></path>
    </svg>
  );
};

Icon.propTypes = {
  pathKey: PropTypes.string
};

export default Icon;

There’s a few bits to discuss here, so let’s break them down:

  • We’re creating a functional component here instead of a class component. I prefer these as I think they’re a lot easier to read. I am also a fan of ES6 classes where appropriate, though (E.G. the system that checks you’re a paying member on this site is an ES6 class).
  • We’re destructuring the props to grab the pathKey item. As you can see, we’re setting a default value of default in case no pathKey prop is set.
  • As described earlier, we have our <svg> shell where the <path>’s d attribute is powered by the passed reference to paths.
  • Lastly, we set our PropTypes as a String and export our Icon function.

Your completed component should look like this:

Code language
js
import React from 'react';
import PropTypes from 'prop-types';

const paths = {
  success:
    'M21.7237 3.42804L7.99978 18.4715L2.27582 12.1972C1.75449 11.6257 0.910494 11.6257 0.390497 12.1972C-0.129499 12.7686 -0.130832 13.6938 0.390497 14.2638L7.05712 21.5714C7.57845 22.1429 8.42244 22.1429 8.94244 21.5714L23.609 5.49464C24.1303 4.92318 24.1303 3.99804 23.609 3.42804C23.0877 2.85805 22.2437 2.85659 21.7237 3.42804V3.42804Z',
  error:
    'M0.502696 2.92661L9.57609 12L0.502696 21.0734C-0.167565 21.7437 -0.167565 22.8288 0.502696 23.4973C1.17296 24.1659 2.25806 24.1676 2.92661 23.4973L12 14.4239L21.0734 23.4973C21.7437 24.1676 22.8288 24.1676 23.4973 23.4973C24.1659 22.827 24.1676 21.7419 23.4973 21.0734L14.4239 12L23.4973 2.92661C24.1676 2.25635 24.1676 1.17124 23.4973 0.502696C22.827 -0.165851 21.7419 -0.167565 21.0734 0.502696L12 9.57609L2.92661 0.502696C2.25635 -0.167565 1.17124 -0.167565 0.502696 0.502696C-0.165851 1.17296 -0.167565 2.25806 0.502696 2.92661Z',
  default:
    'M7.92669 23.4979L18.2123 13.2123C18.8826 12.542 18.8826 11.4569 18.2123 10.7883L7.92669 0.50271C7.25641 -0.16757 6.17128 -0.16757 5.50271 0.50271C4.83414 1.17299 4.83243 2.25812 5.50271 2.92669L14.5763 12.0003L5.50271 21.074C4.83243 21.7442 4.83243 22.8294 5.50271 23.4979C6.17299 24.1665 7.25812 24.1682 7.92669 23.4979V23.4979Z'
};

const Icon = ({pathKey = 'default'}) => {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 24 24"
      aria-hidden="false"
      focusable="false"
      width="1em"
      height="1em"
      fill="currentColor"
    >
      <path d={paths[pathKey]}></path>
    </svg>
  );
};

Icon.propTypes = {
  pathKey: PropTypes.string
};

export default Icon;

Now we’ve got that, let’s create the main component. Inside js/components, create ProgressButton.js. Inside that new file, add the following:

Code language
js
import React, {useEffect, useState} from 'react';
import PropTypes from 'prop-types';

import Icon from './Icon.js';

const ProgressButton = ({defaultText, errorText, successText}) => {};

ProgressButton.propTypes = {
  defaultText: PropTypes.defaultText,
  errorText: PropTypes.string,
  successText: PropTypes.string
};

export default ProgressButton;

We’re following an almost identical pattern here, but this time we’re destructuring a bit more out of React, which will all become clear in a moment. We’re also importing our Icon component. The rest is ground we’ve already covered, so let’s fill this shell component! Inside the ProgressButton function, add the following code:

Code language
js
const [status, setStatus] = useState('waiting');
const [loaded, setLoaded] = useState(0);

const setButtonState = state => {
  setStatus(state);
  setTimeout(() => setStatus('waiting'), 5000);
};

const submit = () => {
  if (status === 'waiting') {
    setStatus('loading');
  }
};

Firstly, we’re using React Hooks a lot in this tutorial. React Hooks gives us this really handy setup where we can destructure a getter and setter for our component state by calling on useState. We also pass the default state. I love this change in React because it makes the code so much more self-explanatory. You know straight away that when I use setStatus, that we’re setting a value for the status state item.

After that, we’ve got a couple of little functions:

  • setButtonState is a helper that sets the button’s state to the passed value, but then resets it after a timeout for us.
  • submit is a click handler which sets the status to loading only if the status is currently waiting, which is the default state. This prevents double submits.

Before the setButtonState function, add the following code:

Code language
js
useEffect(() => {
  const availableStates = ['error', 'success'];

  switch (status) {
    case 'loading':
      if (loaded >= 100) {
        setButtonState(
          availableStates[Math.floor(Math.random() * availableStates.length)]
        );
        setLoaded(0);
        return;
      }

      return setTimeout(() => setLoaded(15 + loaded), 500);
  }
});

useEffect is another React Hook which provides a handy central place to handle lifecycle events. It fires (by default) when something changes in state, so we use this to “fake” a loading timer.

We set the loaded state item, which in turns fires another useEffect because the state has changed. Because we then increment that value in a setTimeout, we get that staggered effect. When loaded hits or exceeds 100, we’re loaded, so we then pick a random item from the availableStates array. Handy!

useEffect is super confusing at first. Luckily, Amelia Wattenberger wrote this incredible article which explains how this and other hooks work.

Add this next block of code at the end of the ProgressButton function (just after the submit function):

Code language
js
return (
  <div className="progress-button" data-state={status} style={`--loaded: ${loaded}%`}>
    <button
      type="button"
      className="progress-button__control"
      onClick={submit}
      disabled={status !== 'waiting'}
    >
      <span className="labelled-icon">
        <span>{defaultText}</span>
        <Icon />
      </span>
    </button>
    <div className="progress-button__alert" role="alert">
      {status === 'error' || status === 'success' ? (
        <div className={`progress-button__state bg-${status}`}>
          <div className="labelled-icon">
            <span>{status === 'success' ? successText : errorText}</span>
            <Icon pathKey={status} />
          </div>
        </div>
      ) : null}
    </div>
    <div aria-live="polite" role="status" className="visually-hidden">
      {status === 'loading' ? <p>Loading. Please wait</p> : null}
    </div>
  </div>
);

This is the return of our function: some JSX. It’s our Progress Button HTML code—but because JSX is dynamic, we can author all of our state changes inline.

The first thing we do is set data-state={status} and style={--loaded: ${loaded}%}. These HTML attributes are used by the upcoming CSS to control various style changes. One is a custom property for the loading state and the other a simple representation of the components current state.

After this, we define our <button> and attach the submit function. We also disable the button on click to again, prevent double submissions.

Instead of changing the label/text of the <button> element for the success and error states, we instead create an alert element which will more effectively communicate the state changes to a screen reader. You can see how this is state controlled when the progress-button__alert element only has content added if the status is success or error. Inside that element we render an appropriate icon and render either successText or errorText.

Lastly, we add an aria-live="polite" role="status" live region which communicates to a screenreader that the button is currently loading when our status is loading. This is because there’s a good chance they won’t be able to see the loading animation, so we need to let them know what’s happening. The polite value for aria-live means that any other more important announcements will happen first.

And that’s our main component done! If you run the project now (go out of src and run npm start) you will see the HTML and JS only version of our Progress Button!


💡 Because we use elements to render our state to screenreaders, we also get the added benefit of if there’s no CSS available, a user will be able to understand the state changes still.


CSS permalink

Let’s make it look good now. Inside the css directory, open up global.css and add the following:

Code language
css
@import url('./reset.css');

/* Token utilities */
@import url('./tokens.css');

/* Main globals */
@import url('./main.css');

/* Components */
@import url('./components/labelled-icon.css');
@import url('./components/progress-button.css');
@import url('./components/visually-hidden.css');

Because we’re using a base project that I put together for React stuff like this, we get PostCSS for free, which let’s us import CSS files (as you can see, we’re already importing our usual reset_). We take advantage of that straight away and import tokens.css and main.css along with three components.


📝 I wrote about this base project, here. It’s an interesting look at the struggles of creating a React project that doesn’t have a huge footprint.


Inside the css directory, create a file called main.css and add the following to it:

Code language
css
:root {
  --color-primary: #21223e;
  --color-primary-light: #3c3f73;
  --color-success: #0c8d87;
  --color-error: #c01332;
  --color-light: #ffffff;
}

body {
  padding: 2rem;
  height: 100%;
  display: grid;
  place-items: center;
  font-family: 'Lato', sans-serif;
}

Here are our :root variables and global body css which is just me applying our font and positioning everything in the center of the screen.

Now, in the same directory, create a file called tokens.css and add the following:

Code language
css
/* State BG utils */
.bg-error {
  background: var(--color-error);
}

.bg-success {
  background: var(--color-success);
}

Because our ProgressButton.js file sets a bg-${status} based on state, we create the CSS classes that set either a green or red background for us.

Now, create a file called visually-hidden.css in css/components and add the following to it:

Code language
css
.visually-hidden {
  border: 0;
  clip: rect(0 0 0 0);
  height: auto;
  margin: 0;
  overflow: hidden;
  padding: 0;
  position: absolute;
  width: 1px;
  white-space: nowrap;
}

This does exactly what it says on the tin and visually hides text. We use this on our loading text element.

Next, create a file called labelled-icon.css in css/components and add the following:

Code language
css
.labelled-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  color: var(--color-light);
  padding: 0.9rem 2.5rem 1rem 2.5rem;
  font-weight: 900;
  letter-spacing: 0.02ch;
  text-transform: uppercase;
  line-height: 1;
  z-index: 1;
}

.labelled-icon > svg {
  margin-inline-start: 1rem;
  flex-shrink: 0;
}

This is a little helper component that aligns a label up nicely with an icon element. It’s all straightforward apart from this cheeky little rule: margin-inline-start: 1rem;. This adds margin to the inline-start which because we read left-to-right in English: it’s on the left hand side. If the language was right-to-left, it would be the right hand side. You can read more on logical properties here.

Now for the main component. Create a file called progress-button.css in css/components and add the following:

Code language
css
.progress-button {
  display: inline-block;
  position: relative;
  border-radius: 0.5rem;
  overflow: hidden;
  font-size: 1.5rem;
  transition: all 200ms ease;
  box-shadow: none;
}

If you remember rightly, the progress-button element is actually a <div> because it’s a container. This makes sure nothing overflows and sets radius. This means everything inside it will be framed nicely.

Next, add the button styles:

Code language
css
.progress-button__control {
  display: inline-block;
  border: none;
  padding: 0;
  margin: 0;
  text-decoration: none;
  background: var(--color-primary);
  color: var(--color-light);
  font: inherit;
  cursor: pointer;
  -webkit-appearance: none;
}

Here, we’re just removing some default button styles and setting the default dark state. Let’s now add our interactive states:

Code language
css
.progress-button[data-state='waiting']:hover,
.progress-button[data-state='waiting']:focus-within {
  transform: translateY(-5px);
  box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.5);
}

.progress-button[data-state='waiting'] .progress-button__control:focus {
  outline: 1px solid currentColor;
  outline-offset: -0.5rem;
}

We only want to show interactive styles when the element can be interacted with, so we use that handy data-state attribute, which is updated with each state change, to make our CSS more specific.

Let’s now add our loader:

Code language
css
.progress-button__control::before {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: var(--loaded, 0%);
  background: var(--color-primary-light);
  transition: width 200ms ease-in-out;
}

We use a ::before pseudo-element on the button itself and then use the state-controlled --loaded custom property to control the width of it.

Finally, let’s wrap up this component with the alert element:

Code language
css
.progress-button__alert {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
}

.progress-button__alert:empty {
  pointer-events: none;
}

We use absolute positioning to fill the progress-button container and then z-index to guarantee that it sits above the button visually. This means that when we are in the success or error state, this alert will sit above and hide the disabled button. If the element is empty, we disable pointer events so clicks can get through to the button still.

That’s all the CSS. Your component should look like this:

Code language
css
.progress-button {
  display: inline-block;
  position: relative;
  border-radius: 0.5rem;
  overflow: hidden;
  font-size: 1.5rem;
  transition: all 200ms ease;
  box-shadow: none;
}

.progress-button__control {
  display: inline-block;
  border: none;
  padding: 0;
  margin: 0;
  text-decoration: none;
  background: var(--color-primary);
  color: var(--color-light);
  font: inherit;
  cursor: pointer;
  -webkit-appearance: none;
}

.progress-button[data-state='waiting']:hover,
.progress-button[data-state='waiting']:focus-within {
  transform: translateY(-5px);
  box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.5);
}

.progress-button[data-state='waiting'] .progress-button__control:focus {
  outline: 1px solid currentColor;
  outline-offset: -0.5rem;
}

/* Loader */
.progress-button__control::before {
  content: '';
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: var(--loaded, 0%);
  background: var(--color-primary-light);
  transition: width 200ms ease-in-out;
}

/* Alert (status/error) state */
.progress-button__alert {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
}

.progress-button__alert:empty {
  pointer-events: none;
}

Progressive Enhancement and the Minimum Viable Experience permalink

This is totally dependent on JavaScript, which isn’t cool. We’ve got the ingredients to create a Minimum Viable Experience now. For me, the Minimum Viable Experience is a button that submits a form, without the fancy stuff, so let’s do that.

Open up index.html and add the following inside <main data-app="main">:

Code language
html
<form action="/" method="post">
	<div class="progress-button">
	  <button type="button" class="progress-button__control">
	    <span class="labelled-icon">
	      <span>Submit</span>
	      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="false" focusable="false" width="1em" height="1em" fill="currentColor">
	        <path d="M7.92669 23.4979L18.2123 13.2123C18.8826 12.542 18.8826 11.4569 18.2123 10.7883L7.92669 0.50271C7.25641 -0.16757 6.17128 -0.16757 5.50271 0.50271C4.83414 1.17299 4.83243 2.25812 5.50271 2.92669L14.5763 12.0003L5.50271 21.074C4.83243 21.7442 4.83243 22.8294 5.50271 23.4979C6.17299 24.1665 7.25812 24.1682 7.92669 23.4979V23.4979Z"></path>
	      </svg>
	    </span>
	  </button>
	</div>
</div>

Because React will empty the contents of this element, we can safely put a static version of our component, inside a form, knowing that it will be replaced. This means that when no JavaScript is available, a user can still submit the data.

And with that, we are done. It’s certainly been a more complex one this time, right?

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!

My favourite attempt for this challenge was by James Bateson. There’s some lovely transition work and the added context with the radio buttons is a nice touch.

Hello, I’m Andy and I’ll help you to level up your front-end development skills.

I'm a designer and front-end developer who has worked in the design and web industries for over 15 years, and in that time, I have worked with some of the largest organisations in the world, like Google, Harley-Davidson, BSkyB, Unilever, The Natural History Museum, Oracle, Capita, Vice Media and the NHS.

On Piccalilli, I share my knowledge and experience to make you a better front-end developer.

I'm the founder of Set Studio, a creative agency that specialises in building stunning websites that work for everyone. Check out what we're all about.