Every Effing thing about Buttons

Buttons are everywhere on internet. They are so common that we even have a button html markup to create them. Creating a custom styled button may seem easy but many things goes into creating an accessible button that works for all states and usecases. So in this article, I will be sharing everything you probably need to know about creating buttons.

The default Button

Let's start with the default button.

In HTML, we can use the <button> tag to render out a button. It 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:

Be alertful when usingbutton inside a form. A button without type attribute inside a form element acts as a submit button. To override this default behaviour, pass type="button".

Can we use a div to create a button?

Before we proceed, have you ever wondered if we can use a regular div as a button? If you think about it, a button is essentially 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 with a bit of CSS:

Few things to note:

  1. Values for border-radius and font-weight dependson the design choices.
  2. We added border-color color of transparent to override the default border color.
  3. We added cursor:pointer explicity to indicate clickability for mouse users. Note that default buttons don't have cursor pointer as according to W3C specification, pointer cursor is meant for links.

We will talking about padding separately in the "Buttons and Icons" section.

Buttons and Icons

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 with Icon

Let's be honest, Buttons without icons looks boring. So let's add the support for icon to our button. For the icon, We can use svg or img tag to render the icon. SVGs are better because they are scalable and we can change the color of svg using fill / stroke property.

We will need to control the size of our icon. We will use em unit for this. Why? em unit is 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 when we will decide to add size variants.

css

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

One important thing for icon's color is that the fill/stroke should be set to "currentColor", so that whatever color we use on buttons'text is reflected on the icon as well.

That 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 icon. We can fix it by adding by adding a constant height to our button so that button's height remains same even if a icon is used in it.

css

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

Few things to note:

  1. We added aria-hidden="true" on the svg icon. Reason being the icon in button are mainly for decorative purpose and we don't want screen readers to annouce it when there is already a text available.
  2. We assigned data-icon attribute to our icon. This only acts a selector for our css.

Buttons with Only Icon

Buttons with only icon are also very common. We can use the same approach as above but 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 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.

Background color vs Text color

When deciding colors for buttons, make sure that there is enough contrast between background and text of the buton so that the text color is properly readable. Use APCA contrast guidelines for this.

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. Also, if you are using styled-components or CSS modules, you can use a for loop to generate these styles.

Focus State

By default, <button> element comes with some focus styles that is dictated by 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;
}

Note how we used :focus-visible pseudo selector instead of :focus. The reason is that for buttons, :focus-visible gets active when button receives focus using keyboard whereas :focus gets active when button receives focus even on clicking. Read more about :focus-visible

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 {
    ...
  }
}
Can't we just use pointer-events: none?

pointer-events: none has the same problem that disabled attribute has. It stops all click events along with focus events. So users can't navigate to the button using keyboard. Special-abled people won't notice that the button exists if we use pointer-events:none. So avoid using pointer-events: nonetoo.

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 )