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! :)


I'm switching back to Linux

Does this mean I'm part of the PC Master Race?

Sometimes flex-direction: row is better for columns

No clickbait subtitle will ever get you to read this