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:
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:
Using button
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" to your 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
:
tabindex
attribute.keydown
event listener and check for the space or enter key.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.
Let's reset the default styles that our browsers put on the buttons:
Few things to note:
border-radius
and font-weight
depends on the design choices. Use the values that fits your design.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.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.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.
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 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;
...
}
We have to add 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.
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:
hidden
prop and referencing it with aria-describedby
attribute.<button>
element<svg>
icon element<button>
element and referencing the <title>
element inside svgWe 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>
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;
}
}
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.
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.
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:
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.
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 loop to generate these styles. For e.g - With CSS modules and with the help of few plugins, you can do something like this:
css
@value colors: accent, neutral, negative, warning, positive;
@each $color in colors {
&[data-variant="filled"][data-color="$(color)"] {
background-color: var(--color-bg-$(color));
color: var(--color-fg-on-$(color));
&:hover:not([data-loading]) {
background-color: var(--color-bg-$(color)-hover);
}
&:active:not([data-loading]) {
background-color: var(--color-bg-$(color)-active);
}
}
&[data-variant="outlined"][data-color="$(color)"] {
background-color: transparent;
color: var(--color-fg-$(color));
&:hover:not([data-loading]) {
background-color: var(--color-bg-$(color)-subtle-hover);
}
&:active:not([data-loading]) {
background-color: var(--color-bg-$(color)-subtle-active);
}
}
&[data-variant="ghost"][data-color="$(color)"] {
background: transparent;
color: var(--color-fg-$(color));
&:hover:not([data-loading]) {
background: var(--color-bg-$(color)-subtle-hover);
}
&:active:not([data-loading]) {
background: var(--color-bg-$(color)-subtle-active);
}
}
}
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;
}
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 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:
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.
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 {
...
}
}
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 be able to notice that the button exists if we use pointer-events:none
. So avoid using pointer-events: none
too.
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.
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;
}
}
}
You might be thinking, why not simply do display: none
on content. Well, we can't do that because we need to keep the button's size same when it goes into loading state.
So we will use visibility: hidden
on content and position: absolute
on loader to keep the button's size same.
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 )