Every Effing thing about Buttons

So recently I was working on a design system for a project and while working on the button component I realized that buttons are not that simple as they seem. There are so many things that goes into creating an accessible button. From different states to different sizes, colors and variants, there are so many things to consider. So I thought why not write an article on it. So here it is.

In this article, we will be talking about different aspects of buttons. We will be creating a button component from scratch. We will be talking about different states, sizes, colors, variants and icons in buttons. At an high level, our button should have following features:

  • Icon support
  • Different sizes (Small, Medium, Large)
  • Different colors (Accent, Positive, Negative, Neutral, Warning)
  • Different variants (Filled, Outlined, Ghost)
  • Different states (Active, Hover, Focus, Disabled, Loading) Let's start with the basics.

The default Button

In HTML, we can use the <button> tag to render out a button. The default button comes with some default styles which varies depending on the browser and platform you are on.

Apart from the regular buttons, there are also few types of input buttons:

Can we use a div as a button?

Have you ever wondered if we can use a regular div as a button? If you think about it, a button is essentially just an element that can be clicked. And you can achieve that with a regular div using the onclick handler, right? So, is it possible to use a div to create buttons?

Yes, you can use a div as a button. However, there are several functionalities that buttons provide by default, which may require additional work when using a div:

  1. Buttons are focusable, while divs are not. To make divs focusable, you need to add the tabindex attribute.
  2. Buttons can be triggered by pressing Enter or Space from the keyboard. For divs, you would need to add a keydown event listener and check for the space or enter key.
  3. Buttons announce their role as a button to assistive technology. For divs, you would need to add the role="button" attribute.

Check out the article by Ben Myers for more detail around this topic. In my opinion, it's okay to built buttons with div as long as your button is accessible. The choice is yours.

Basic Styling

Let's reset the default styles that our browsers put on the buttons:

Few things to note:

  1. Values for border-radius and font-weight depends on the design choices. Use the values that fits your design.
  2. We added border-color color of transparent to override the default border color. There is an important another reason for this. Later on, we will be adding different variants of buttons like filled, outlined and ghost. For outlined buttons, we will be using border color. So, we are setting the default border color to transparent so that height and width of the button remains same when we switch to outlined variant. Read more about this here.
  3. We added cursor:pointer explicity to indicate clickability for mouse users. Note that default buttons don't have cursor pointer. According to W3C specification, pointer cursor is meant for links.
  4. Put a pin on the padding value for now. We will be talking about padding separately in the "Buttons and Icons" section.

Before we talk about different styles and states of a button, Let's first talk about the relationship between buttons and icons because addition of icons inside buttons affects their size. The earlier we fix the sizing/layout, the better.

Buttons and Icons

There are two types of buttons that goes with icons: Buttons with icons and Buttons with only icons ( also known as Icon buttons ). Let's talk about them one by one.

Buttons with Icon

Buttons with icons are very common. They are used to indicate the action that the button will perform. For e.g - a button with a trash icon will indicate that the button will delete something. Now to add an icon, We can use svg or img tag. SVGs are better because they are scalable and also, we can change the color of svg using fill / stroke property which is not possible with img tag.

One important thing to note before we proceed further that we are using stroke="currentColor" and fill="none" in the svg. This is because we want to change the color of the icon using the color property of the button. If we use fill="currentColor", the color of the icon will be same as the color of the text of the button.

The icon size is out of control. We need to control it. For this, we can use em unit. Why em? If you think about it, em unit is actually perfect for this usecase as using it, our icon will scale according to the size of the button's text. It help us later on too when we will decide to add size variants.

css

& [data-icon] {
  width: 1.2em;
  height: 1.2em;
}

We are using data-icon attribute to target the icon. You can use class as well to target the icon.

That still does not look alright. Our icon is not centered. We can fix the layout with flex.

css

button {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  ...
}

This looks good but there is one tiny problem. The height of our button got increased due to the addition of icon. We can fix it by adding by adding a constant height to our button so that button's height remains same even if an icon is used in it.

css

button {
  ...
  block-size: 32px;
  ...
}

Since we added a fixed height to our button, we can remove the vertical padding from our button. We can use padding-inline instead of padding to keep the horizontal padding.

css

button {
  ...
  padding-inline: 12px;
  ...
}

Buttons with Only Icon

Buttons with only icon (also known as Icon Buttons) are nothing but buttons with just icons. Icon buttons are preferred to be square. So we will need to make sure our icon button is of fixed height and width, and these styles should only apply when the button is of type "icon button". To differentiate between normal button and icon button, we can add an attribute data-icon-button in case of icon buttons and add styles targeting [data-icon-button] attribute.

css

&[data-icon-button] {
  block-size: 32px;
  inline-size: 32px;
  padding: 0;
}

Ok now our icon button looks good. But there is one accessiblity issue. There is no text in our icon button. So we need a text which can be annouced to assistive technologies. There are around 5+ ways to address this issue as suggested by Sara Soueidan:

  1. Using a visually hidden text
  2. Using a visually hidden text with hidden prop and referencing it with aria-describedby attribute.
  3. Using aria-label on the <button> element
  4. Using aria-label on the <svg> icon element
  5. Using aria-labelledby on the <button> element and referencing the <title> element inside svg

We will go with #3 option as it seems like simplest one.

html

<button data-label="Add to favorites" data-icon-button data-variant="filled" data-color="accent">
  <svg data-hidden="true" data-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none">
    <path d="M6.979 3.074a6 6 0 0 1 4.988 1.425l.037 .033l.034 -.03a6 6 0 0 1 4.733 -1.44l.246 .036a6 6 0 0 1 3.364 10.008l-.18 .185l-.048 .041l-7.45 7.379a1 1 0 0 1 -1.313 .082l-.094 -.082l-7.493 -7.422a6 6 0 0 1 3.176 -10.215z" stroke-width="0" fill="currentColor" />
  </svg>
  Button with Icon
</button>

Size

The need for buttons of different sizes is common. For e.g - You might want to use a smaller version of your button where the space is compact. In this section, we will creating three size variants for our buttons - "small", "medium" ( default ) and "large". Let's define the markup first.

html

<button data-size="small">Small</button>
<button data-color="medium">Medium</button>
<button data-color="large">Large</button>

One important thing to note is that we want to control two things for each size variants: font-size and sizing (height / width and padding).

Let's first add styles for one variant:

css

button {
  ...

  &[data-size="small"] {
    font-size: 0.8rem;
    block-size: 24px;
    padding-inline: 6px;
  }
}

We can improvise here. We are updating three values here to the size variant. Wouldn't be cool if we just update the font-size and everything else is updated as well? To do so, we can do use em unit for padding and block-size. ( I am telling you em is an underated css unit).

css

button {
  block-size: 2em;
  padding-inline: 0.6em;
  ...

  [data-icon-button] {
    block-size: 2em;
    inline-size: 2em;
    padding: 0;
  }

  &[data-size="small"] {
    font-size: 0.75rem;
  }

  &[data-size="large"] {
    font-size: 1.25rem;
  }
}

Colors

Buttons as an element can have different jobs, and sometimes they're about doing important things, like deleting a record. So, it's crucial to make sure they're clear about what they do. One way to do this is by using semantic colors that can indicate the intent of the action. For e.g - we can use red tone color for destructive actions like "Delete Account" or "Cancel Subscription". This way, users would know what they're getting into before they click.

So, we will define multiple semantic colors for our button. You may not need all of them. Just include the one as per your needs.

Variants

Apart from different intents of a button, Buttons can also have different priority. For e.g - Submit button in a form would have more priority then a cancel button. So they need to look different. The way we create the difference is by creating variants of different styles. For e.g - Filled Background button would hold more priroty than a outlined transaprent button.

Usually, buttons variants are created with names like primary, secondary, or tertiary. But there's a better way: filled, outlined, and ghost. These names are way clearer - you can actually picture how your button will look!

Here's the lowdown on each style's importance level:

  • Filled: Denoting primary actions, filled buttons utilize a solid background color to create a visually prominent and impactful element.
  • Outlined: Emphasize secondary or alternative actions. Outlined buttons utilize a border to delineate their shape while maintaining a less visually dominant presence compared to filled buttons.
  • Ghost: Communicate subtle or less critical actions. Ghost buttons employ a transparent background and often rely solely on a border or text color for definition, making them visually understated.

For each variant, we will need 5 semantic color styles. That is total of 15 styles for buttons if we do all the permutation and combinations.

States

Hover and Active State

There should be a clear distinguishen between normal states vs hover/active (pressed) state. The difference can be showed by change in background color or by a subtle motion. CSS provides us pseudo selectors to target these states with :hover and :active selectors.

css

&, &[data-variant="filled"] {
  background-color: var(--color-bg-accent);
  color: #fff;

  &:hover {
    background-color: var(--color-bg-accent-hover)
  }

  &:active {
    background-color: var(--color-bg-accent-active);
  }
}

Similarly we can add the styles for other variants too.

That's a lot of styles! But it's worth it. Now, users can easily distinguish between different buttons and their actions.

Focus State

By default, <button> element comes with some focus styles that is dictated by the browsers. So if you focus on any of the previous button above, you will see a some focus styles depending on the browser you are using. We can update the focus styles to match our design.

css

&:focus:not(:focus-visible) {
  outline: none;
}

&:focus-visible {
  outline: 2px solid #1d83f7;
  outline-offset: 2px;
}

Disabled State

Disabled state is the most misundestood state in buttons. What people generally do to mark a button as disabled is pass the disabled attribute.

html

<button disabled>Disabled Button</button>

Passing the disabled attribute does two things for us:

  • It stops click/press event from being triggered.
  • It prevents the button from being focused.

Note that using disabled attribute won't stop :hover or :active styles. They continue work as usual.

Using disabled attribute may seem useful but there are one major problem with it. Special-abled people who use keyboard to navigate the websites will not be able to navigate to the button. Worse, they might not even know there exists a button that is disabled. So we have to make sure that we avoid using disabled attribute in our button. But wait, then how do we tell our users that the button is disabled without using disabled attribute? 🤔
Well, There are two solutions to this problem as suggested by Sandrina Pereira.

Solution #1 Use tooltips

In this approach, we avoid using disabled attribute, instead pass a aria-disabled attribute and style our buttons targetting with [aria-disabled] selector.

css

&[aria-disabled] {
  opacity: 0.6;
  cursor: not-allowed;
}

Note that using the aria-disabled does not actually do anything except helping us annoucing to assitive technologies that button is disabled. We can still click on button and trigger the onclick events. So we will need to tweak our onclick function so that nothing happens on click when button is disabled.

js

const onClick = () => {
  const isDisabled = elButtonSubmit.getAttribute('aria-disabled') === 'true';

  if (isDisabled) return;

  // Do some async work here...
}

But wait, there is still one UX problem. We are not telling our users why the button is disabled. We can't simply leave our users guessing right? So to solve this, We can use tooltip on our button to tell users why the button is disabled. When using tooltips on button, we need to make sure we are using proper aria attributes to enhance the experience for assistive technologies.

Our hover and active styles are still working . They shouldn't work on disabled buttons right?. So we will need to tweak our selector in our styles a bit.

css

button {
  ...
  &:not([aria-disabled]):hover {
    ...
  }

  &:not([aria-disabled]):active {
    ...
  }
}

Solution #2 Don't use disabled buttons at all

Solution #1 works great but our users will be uncertain about button why its disabled until they hover. Also, we altered styles and made it little dim which might not look good visually.

Another way to solve issues with disabled buttons is avoid using them at all. Instead of disabling the button, we allows click and show a feedback on click.

Loading State

Buttons can be used to do some async tasks. When doing async tasks on button clicks, it's wise to show some feedback to the user that some async task is happening. The most common way of showing a feedback is to show a loader icon on the button. Let's add a loader icon to our button.

First, we need a to change structure of our button a little. We need to put our button label in a span, our loader icon in another span and add a data-loader and data-content attribute to them respectively.

html

<button>
  <span data-content>Do something</span>
  <span data-loader aria-hidden="true"> 
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.364 5.63604L16.9497 7.05025C15.683 5.7835 13.933 5 12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12H21C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C14.4853 3 16.7353 4.00736 18.364 5.63604Z"></path></svg>
  </span>
</button>

As we learnt in "Button with only Icon" section, screen readers can't tell from the icon what does it mean, We will use aria-hidden attribute on the icon span to hide the loader icon from screen readers.

So by default this loader will be hidden.

css

& [data-loader] {
  display: none;
}

& [data-loader] [data-icon] {
  animation: spin .5s linear infinite;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}

And, when the loader goes into loading state, we will show the loader and hide the content. Note we need something that can represent our loading state so that we can target that state and use in CSS to show/hide the loader. It can a class like .loading or .is-loading or a data attribute like [data-loading]. We will go with data attribute.

css

button {
  position: relative;
  ...

  &[data-loading]  {
    & [data-content] {
      visibility: hidden;
    }

    & [data-loader] {
      display: flex;
      position: absolute;
      inset: 0;
      justify-content: center;
      align-items: center;
      border-radius: inherit;
    }
  }
}

So on click, we toggle our data-loading data-attribute.

js

const onClick = (e) => {
  const currentButton = e.currentTarget;
  const isLoading = currentButton.hasAttribute('data-loading');

  if (isLoading) return;

  elButtonSubmit.setAttribute('data-loading');

  // Note: We are using setTimeout to simulate the async work
  setTimeout() => {
    elButtonSubmit.removeAttribute('data-loading');
  }, 3000);
}

There is still one major problem we didn't tackle. Our button is not accessible for loading state. Since we used aria-hidden for our [data-loader], our button is not annoucing to the assistive technologies that it is doing some aync task. For this, we will use a visually hidden text to annouce to assistive technologies that button is in loading state.

text

<button>
  <span data-content aria-hidden="true">Add to Cart</span>
  <span data-loader aria-hidden="true"> 
     <svg data-icon xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.364 5.63604L16.9497 7.05025C15.683 5.7835 13.933 5 12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12H21C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C14.4853 3 16.7353 4.00736 18.364 5.63604Z"></path></svg>
  </span>
  <span class="visually-hidden" aria-live="assertive"></span>
</button>

The way aria-live works is as soon as the content of live region ( div with aria-live) changes, the screen readers will start annoucing. So we will need to change the content with javascript.

js

const onClick = (e) => {
  const currentButton = e.currentTarget;
  const isLoading = currentButton.hasAttribute('data-loading');
  
  if (isLoading) return;

  currentButton.setAttribute('data-loading', "");
  currentButton.querySelector('[aria-live]').textContent = 'Wait, Adding to cart.';

  // Note: We are using setTimeout to simulate the async work
  setTimeout(() => {
    currentButton.removeAttribute('data-loading');
    currentButton.querySelector('[aria-live]').textContent = ''; 
  }, 3000);
}

document.querySelector('button').addEventListener('click', onClick);

Ok now, If you use voiceover or ndva, you will see that when our button goes in loading state, it annouces the state. But what about post loading state? We should also announce that async work is done. This is mostly handled by another live region that can render a toast or simply status message outside of button.

Note in the above example, we didn't address what happens after async task is done and how to annouce it to the user. We will leave that for you to figure out ( Hint: You will need a toast with aria-live attribute )