DEV Community

Vesa Piittinen
Vesa Piittinen

Posted on

Should I ever have a role="button"?

The answer is no. You should always use <button />.

However there exists one valid niche use case. You have a design where you have an element that looks like a link inside other running text. This means the element should wrap just like the text! So isn't this just a link? Well, there is a gotcha: for whatever reason the behavior of the element is not to navigate to another url but to do a button action.

Now, you can argue that this is design gone bad, and you're right. However in real life it is hard to avoid this kind of issues since the process has to be good and people aware enough for this not to ever happen. So it keeps happening.

How should we handle this use case?

So much to do!

The big problem when going ARIA route is the need to do so much to get things right. We have these considerations:

  • Should visually behave like any other link would
  • Should functionally behave like a button
  • Should have focus indication
  • Should be announced as button for a screen reader

We would like to avoid all this. It would be so simple to just use <button /> and style it as a link. But can we do that?


Visually as a link

You could go ahead and duplicate your link styles for this element. You might also think that using <button /> element would be fine, just drop in display: inline; and good to go, right?

Well, the answer is no. <button /> will never respect your display: inline; no matter how much !important you throw at it. It won't budge: it'll always be a minimum of display: inline-block;. That's a bummer. Shouldn't CSS have control over everything?

There are likely good historical reasons for buttons not to respect display: inline;. To me it is enough to know that it just doesn't work. And because our use case is a link that should wrap just like all the other text, well, <button /> just simply can't meet that criteria.

So what should we do then? Use a <span /> element? It is a possibility. However I think it is easier to actually make use of the anchor element since this means you can handle the issue in your normal link styles! This kind of means "zero styles" necessary for a custom element, no need for className etc.

So what should our style rules look like?

/* Note: we support `<a role="button" />` */
a {
    /* Provide your link color to anchor element. */
    color: royalblue;
    /* Links have a pointer cursor. */
    cursor: pointer;
    /* Links probably should always have underline. */
    text-decoration: underline;
}
Enter fullscreen mode Exit fullscreen mode

There aren't really much use for anchors on the web these days so this shouldn't cause any undesired side effects on any modern website.

The nice thing about this is that you get to handle the issue with rather minimal CSS, and it can happen in your normalize.css or equivalent place where you handle default styles. It makes sense since ideally we'll be using the native anchor element directly in our code, not a component that renders an anchor.


Functionally like a button

We can do just <a onClick={() => {}} /> and call it a day, right?

Wrong! You also need to add in keyboard functionality. And that means there are multiple related things that need to be done. First and foremost the element needs to become accessible by keyboard. For that we need tabIndex!

<a onClick={onClick} tabIndex={0}>Looks like a link!</a>
Enter fullscreen mode Exit fullscreen mode

So are we done? We can now focus the element with a keyboard and get a default focus indication on it, too! However there is more to a <button /> functionality. Links navigate when you press the enter key. Buttons do their action with enter. However buttons also do their action when you press the space key! And in this case we have an anchor element which reacts to neither, because anchor is not a link.

So we need to handle enter and space keys and add onKeyDown handler.

function onKeyDown(event: React.KeyboardEvent<any>) {
    if (event.isDefaultPrevented() || !(event.target instanceof HTMLElement)) return
    if ([' ', 'Enter'].includes(event.key)) {
        event.target.click()
        event.preventDefault()
    }
}

<a onClick={onClick} onKeyDown={onKeyDown} tabIndex={0}>
    Looks like a link!
</a>
Enter fullscreen mode Exit fullscreen mode

This event handler won't execute if event.preventDefault() has been called by someone before this handler executes. It makes sense since we're implementing default behavior. We are reimplementing how the web works so we also should behave similarly. So while it might be a rather edge case it is one potential future bug less when we respect the world of DOM, and give an option to skip the behavior.

The other part of the first if condition is to "make TypeScript happy".

But hey, are we done?

I'm happy to let you know that we are not done. There is one thing in Firefox: it lets you drag anchor elements. However we don't want this behavior and it is a bit annoying that element might start moving upon click, and then not click due to entering dragging mode.

<a
    draggable={false}
    onClick={onClick}
    onDragStart={(event: React.DragEvent) => event.preventDefault()}
    onKeyDown={onKeyDown}
    tabIndex={0}
>
    Looks like a link!
</a>
Enter fullscreen mode Exit fullscreen mode

Darn it. How to remember all of that? You might be able to omit draggable but it might be a safer bet to have it to let everything absolutely know that we don't want dragging on this element.


Announced as a button

This is one more attribute, but this time we can get through with just one addition:

<a
    draggable={false}
    onClick={onClick}
    onDragStart={(event: React.DragEvent) => event.preventDefault()}
    onKeyDown={onKeyDown}
    role="button"
    tabIndex={0}
>
    Looks like a link!
</a>
Enter fullscreen mode Exit fullscreen mode

With that we are finally done!

But... that is awful lot of code to repeat every single time we have a need for this, and it is too easy to forget something being done.

One solution would be to make this a component. Call it a TextLinkButton or something! However there is one gotcha with components: it hides the semantics of what we're doing. People also expect components to contain their own styles, but in this case we want to rely on default or generic styles. So by making this a component we break one ideal, or an expectation other developers might have.

Utility props

There is another way to deal with the issue of too many props: just provide an object to be shared!

// buttonRoleProps.ts

function onKeyDown(event: React.KeyboardEvent<any>) {
    if (event.isDefaultPrevented() || !(event.target instanceof HTMLElement)) return
    if ([' ', 'Enter'].includes(event.key)) {
        event.target.click()
        event.preventDefault()
    }
}

function preventDefault(event: any) {
    event.preventDefault()
}

/** Usage: `<a {...buttonRoleProps} />` */
export const buttonRoleProps: React.HTMLProps<any> = {
    draggable: false,
    onDragStart: preventDefault,
    onKeyDown,
    role: 'button',
    tabIndex: 0,
}
Enter fullscreen mode Exit fullscreen mode

These props have been designed so that references to functions remain always the same. This reduces the burden of changing DOM on render for no-good-reason.

There is of course one tradeoff being made here: if for whatever reason you need to customize onKeyDown you have to re-implement the space and enter key support. However I feel like this is becoming such a niche case of a niche case that it is just better add documentation to the utility like "remember to handle space and enter keys if you use custom onKeyDown handler" rather than solving the issue.

With that we now have a rather complete solution with very few lines of additional CSS, and with minimal JavaScript code! All needs for basic button behavior and expectations have been tackled, and developer experience is very simple as well:

return (
    <>This is text <a {...buttonRoleProps} onClick={onClick}>that has button looking like a link</a> within!</>
)
Enter fullscreen mode Exit fullscreen mode

But solving the issue

Overkill mode is now active. In my opinion this is absolutely too much code for this issue.

// buttonize.ts
import { buttonRoleProps } from './buttonRoleProps'

const cache = new WeakMap()

const buttonize = (
    props?: JSX.Element | React.HTMLProps<any> | null | false
): JSX.Element | React.HTMLProps<any> => {
    if (!props) return buttonRoleProps

    if ('onKeyDown' in props && typeof props.onKeyDown === 'function') {
        const { onKeyDown } = props
        // having this memoize might also be overkill...
        if (!cache.has(onKeyDown)) {
            cache.set(onKeyDown, (event) => {
                onKeyDown(event)
                buttonRoleProps.onKeyDown(event)
            })
        }
        return { ...buttonRoleProps, ...props, onKeyDown: cache.get(onKeyDown) }
    }

    if (React.isValidElement(props)) {
        return React.cloneElement(props, buttonize(props.props))
    }

    return { ...buttonRoleProps, ...props }
}
Enter fullscreen mode Exit fullscreen mode

What you can now do with this:

// use as props:
<div>
    <a {...buttonize({ onClick, onKeyDown })}>I can have focus</a>
</div>

// pass in element:
<div>
    {buttonize(<a onClick={onClick} onKeyDown={onKeyDown}>I can have focus</a>)}
</div>

// compared to (here onKeyDown would also have to handle enter & space):
<div>
    <a {...buttonRoleProps} onClick={onClick} onKeyDown={onKeyDown}>I can have focus</a>
</div>
Enter fullscreen mode Exit fullscreen mode

For some other use case this kind of helper utility might be worth it. But here? We're dealing with just one function callback that needs the default behavior to be available after a custom handler. Which is likely not needed, ever, in any project.

Of course the code amount isn't that bad but it's still something for something that isn't really needed.


Closing it

So what did we learn here? Well, there are quite a bit of considerations to make an element work like a real button! Doesn't this highlight how much work the native web standards do for you that you might never wonder about yourself? Embrace the <button />, always! It is a truly awesome little piece of code. Even if you probably have to write <button type="button" /> way too often, because not every button is a submit button!

There is still also one little consideration that occurred to me while making this article: user-select: none;. This is how typical buttons behave regarding text selection. So why didn't I bring it up earlier? Because after thinking about it we're dealing with a text link. You are supposed to be able to select the text, and should not disable it here. The button made here looks like it is part of the text so selection is an expected behavior.

But if at all possible, please avoid having a need for this in the first place! Yes, I know, it keeps happening to everyone. It always finds it's way in at some point when people develop websites. But maybe consider your design and development process and how it would make sure usability and tech specs are met in design?

So that it would be noticed that "hey, this isn't really a link, it is a button, it shouldn't look like a link" can happen when features are still in the design pipeline and not during development when changing a design on-the-fly might be rather costly. And then we end up with anchor elements that look like links that are really buttons. Or something worse.

Discussion (7)

Collapse
sargalias profile image
Spyros Argalias

Nice post. Thanks for pointing out this niche case.

Collapse
iwashiding profile image
IWasHiding

Amazing post , thank you so much , but i think that the introduction is a little bit not clear , i suggest to update it.

Collapse
lukeshiru profile image
Luke Shiru

Isn't it better to use an inline button with link styles, instead of turning a link into a button functionality wise?

My question mainly is because, as I see this, you need a button that looks like a link, not a link that behaves like a button.

Collapse
merri profile image
Vesa Piittinen Author

Try display: inline; on a button and see if anything happens, and if that works on a text in a middle of a paragraph.

Collapse
lukeshiru profile image
Luke Shiru

Thread Thread
merri profile image
Vesa Piittinen Author • Edited on

Make button contents wrap like a link. Have more than a single word.

It bugs out to it's own row if it has to wrap.

Thread Thread
lukeshiru profile image
Luke Shiru

I get what you mean about trying to style that button if it has a really long text inside of it ... but is it to often that you get long texts inside buttons? It might be a solution with CSS I didn't found. I still think the problem here is that you need a button that looks like an a, not an a that behaves like a button (from starters, as you yourself said it, is not great to have a button in the middle of a text). So generally the first step is to push back UX, or product, or whoever asked for a button inside a text, and explain is not great for visual, behavior and a11y.

Don't get me wrong, if you absolutely have to create an anchor that behaves like a button, then by all means do it ... but for me that's like option 3:

  1. Try to push back on that "requirement", if that doesn't work...
  2. Try to use a button styled like an a, and if that doesn't work...
  3. Make an a behave like a button.