The HTML button tag is a mess

Tom Golden
Tom Golden

What happens when you click a button?

You would hope it would:

  • fire its click event listeners
  • receive the browser focus
  • submit its parent <form> if it was given type="submit"

What if you don't use a pointing device?

You would hope they could:

  • focus the button with the keyboard
  • simulate a button click (as specified above) by pressing either Enter or Space

Awesome!

Except... the <button> tag doesn't do this.

At least, not consistently across all the major browsers.

Here's a link to a breakdown of different browsers input compatibility inconsistencies. There's a lot there, because there's a lot of inconsistencies...

So how do we do it?

I explored a few alternatives and came to realize that the least worst solution is to use <div tabindex="0">, with event listeners to "polyfill" (read: wrap with secret functions) the default behavior of the <button> tag.


Accessible buttons aren't particularly sexy, so here's some example React code with some comments that highlight the main ideas behind an abstracted solution:

const React, { Fragment, useRef } from 'react'; const { number, string, func, bool } from 'prop-types'; const Button = ({ onClick: _onClick, onKeyDown: _onKeyDown, tabIndex, type, ...props }) => { const hiddenInputEl = useRef(null); // wrap click event to not fire the callback when disabled const onClick = ev => { if (!disabled) { _onClick(ev); // clicks the hidden input, if it has one - e.g. to trigger a form submit if (hiddenInputEl.current && hiddenInputEl.current.click) { hiddenInputEl.current.click(); } } }; // wrap keydown event to call onClick when Enter or Space is pressed const onKeyDown = ev => { const { preventDefault } = ev; let preventedDefault = false; ev.preventDefault = (...args) => { preventedDefault = true; preventDefault.apply(ev, args); }; _onKeyDown(ev); if (!preventedDefault && [' ', 'Enter'].includes(evt.keyCode)) { _onClick(ev); } }; return ( <Fragment> <span onClick={onClick} onKeyDown={onKeyDown} tabIndex={tabIndex} type={type} {...props} /> { (type === 'button') ? null : ( // hidden input to polyfill type="submit" and type="reset" behavior <input {...props} type={type} ref={hiddenInputEl} tabIndex={-1} style={{ position: 'absolute', height: 1, width: 1, opacity: 0, overflow: 'hidden', pointerEvents: 'none', clip: 'rect(1px, 1px, 1px, 1px)' }} /> ) } </Fragment> ); }; Button.defaultProps = { tabIndex: 0, role: "button", type: "button", onClick: () => {}, onKeyDown: () => {}, disabled: false }; Button.propTypes = { tabIndex: number.isRequired, role: string.isRequired, type: string.isRequired, onClick: func.isRequired, onKeyDown: func.isRequired, disabled: bool.isRequired };

If there's an error with the code, or you have some additional suggestions please reach out to me by email and I'll get back to you! :)


Don't use CSS minifiers with SASS

CSS minifier/compressor benchmarks and the cold-hearted reality

Reading every word on Wikipedia using Node.js

Making Node do exactly what it wasn't designed for (and do it well)