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


Reading every word on Wikipedia using Node.js

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

I'm switching back to Linux

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