Skip to main content

How to (not) make a button

Today’s web is a very interactive experience, yet we often fail to provide the same experience to everyone.

Complex form widgets, interactive configurators, tables, shop listings, and the list goes on. We should use buttons and links to interact with those components. But often, we just put an onClick handler on a div and call it a day.

That leads to a poor experience for some users.

I want to show you how to properly turn a div into a button, in case you ever need it. So the result will be accessible and useable by everyone.

Why would you want to create a custom button?

Permalink to “Why would you want to create a custom button?”

There may be various reasons for this.

We may have limited control over the markup (eg. when we use 3rd party library). Or there is some specific use case that doesn’t seem to be a good fit for a button or a link (and I have seen a lot of them in my career). For example:

  • Interactive clickable card with the product info and other controls (add to cart, compare, etc.)
  • Nested controls (a button with another button inside)
  • Clickable table rows.

While having limited control over markup may be hard to overcome. We can often solve these specific cases with a little bit of effort.

Changing the design may be a valid option in some cases. But often we just need to rethink the markup a little bit. Here are some links for common patterns and how to do them correctly:

But if you think you still have to build a custom button, then read on:

Your first step will most likely be adding an onClick handler. So let’s start with this:

<div onClick={doSomething}>Click me. I'm a fake button</div>

Often this is also where it ends.

But as soon as you try to use a keyboard to interact with your new button, you will find that you can’t select it using the Tab key.

So let’s fix that and add a few more properties.

<div tabindex={0} onClick={onClickButtonHandler} onKeyDown={onKeyDownHandler}>
Click me. I'm a fake button
</div>

We have to add a tabindex attribute with the value 0. This will make the button focusable when using a keyboard (an important part of any accessible interface).

Next, we have to assign a keydown event listener to our custom button. HTML button can be pressed using Enter and Space keys. So we need to add that behavior as well. The listener callback will look something like this:

function onKeyDownHandler(event) {
if (event.key === "Enter" || event.key === " " /* space */) {
// We have to disable default space behavior which is scrolling page down.
event.preventDefault();
onClickButtonHandler();
}
}

But in reality, this is not exactly correct behavior. The native button works slightly differently, as Adrian Roselli pointed out in his article Brief Note on Buttons, Enter, and Space.

Enter triggers the native button on the keydown event, but Space triggers the button on the keyup event (the press with Space can be canceled). If we want to mimic this behavior we have to update our handlers again:

<div
tabindex={0}
onClick={onClickButtonHandler}
onKeyDown={onKeyDownHandler}
onKeyUp={onKeyUpHandler}
>
Click me. I'm a fake button
</div>
function onKeyDownHandler(event) {
if (event.key === " ") {
// We have to disable default space behavior which is scrolling page down.
event.preventDefault();
}
if (event.key === "Enter") {
onClickButtonHandler();
}
}

function onKeyUpHandler(event) {
if (event.key === " " /* space */) {
onClickButtonHandler();
}
}

Right now, we have a fake div button that users can click on. And they can control it with a keyboard as well. But we still have to do a few more things to make this button accessible. First, we need to tell screen readers that this is really a button, not a div. We will add the role="button" attribute. Without it, screen readers won’t announce this as a button to the users.

<div
tabindex={0}
onClick={onClickButtonHandler}
onKeyDown={onKeyDownHandler}
onKeyUp={onKeyUpHandler}
role="button"
>
Click me. I'm a fake button
</div>

Are we done?

For the basic functionality, yes. But what if you need to disable that button?

For an HTML button, you would just use the disabled attribute and the browser would take care of everything (default styles, turn off the interactivity, etc.). But that is not going to work for our fake button.

We have to use the aria-disabled attribute. But we also have to handle the styling and also disabling of the event handlers. You might also want to prevent the button to be unfocusable via tab. All this means just more work for us.

This is our final code for a button with support for the disabled state:

<div
tabindex={0}
onClick={onClickButtonHandler}
onKeyDown={onKeyDownHandler}
onKeyUp={onKeyUpHandler}
role="button"
>
Click me. I'm a fake button
</div>
function isDisabled(eventTarget) {
const ariaDisabled = eventTarget.getAttribute("aria-disabled");
const isDisabled = ariaDisabled === "" || ariaDisabled === "true";

return isDisabled;
}

function onKeyDownHandler(event) {
if (event.key === " ") {
// We have to disable default space behavior which is scrolling page down.
event.preventDefault();
}
if (event.key === "Enter" && !isDisabled(event.target)) {
onClickButtonHandler();
}
}

function onKeyUpHandler(event) {
if (event.key === " " && !isDisabled(event.target)) {
onClickButtonHandler();
}
}

Quite a lot of code for a simple component, right? And even if you write all this code you still don’t get the full button feature set[1].

I hope by this time that you realize that instead of writing all this code yourself, it is much easier to write this code and get all the goodies for free:

<button type="button" onClick={onClickButtonHandler}>
Click me! I'm a real button.
</button>

Isn’t that much easier?

While the button seems to be a simple component, which we can easily hack together with the div and onClick handler, the opposite is true.

I hope you have learned how complex it is to create a good button component and how much time and effort you can save using a native button.

And your users will probably thank you as well.

Want to learn more? Check out these amazing resources:

10 October 2022 - Added e.preventDefault() to event handlers. Aaron corretly pointed out, that I forgot to disable native space behavior to scroll the page down.

  1. We created a generic button, but the HTML button has more types. It can submit or reset a HTML form, when nested inside <form> tag without any extra JS (aside from handling the submitted data).

    And when we add a form attribute on the button to connect it to a form. We can do that even if the button is outside of the <form> element. ↩︎

Tomas Pustelnik

Front-end developer with focus on semantic HTML, CSS, performance and accessibility. Fan of great and clever design, tooling addict and neverending learner. Building Clipio in my free time and writing on this blog.