Every time you add a click handler to a div, a puppy dies
Every time you add a click handler to a div or an svg (or any other non-interactive element),
a puppy dies somewhere.
That’s the whole post. But if you’d like to know why the puppy dies or what to do when a
button feels like the wrong tool, read on.
HTML defines a specific set of interactive content:
<a>(with anhref)<button><input>,<select>,<textarea>,<label><details>/<summary><audio>/<video>(withcontrols)<iframe>,<embed>,<img usemap>
Notice what’s not on the list: div.
Also span, li, svg, td are not there.
A div with a click handler seems the same so long as all you do is click it. But it’s not. It’s missing:
- Keyboard focus. You can’t Tab to it. For someone who navigates by keyboard, it does not exist.
- Keyboard activation. A focused button fires on Enter and Space. A div fires on nothing.
- A role. A screen reader announces
<button>Save</button>as “Save, button”. It announces your div just as the text inside it with no hint that it’s clickable. - Everything else.
:focus-visiblestyles,disabledsemantics, participation in form submission, correct behavior with voice control software (“click Save” works because the accessibility tree knows Save is clickable).
These look the same but one is a <div> and the other a <button>.
<div>: clicked 0×
Okay you clicked on them. Now try the keyboard:
- click on one of the buttons to focus it
- press Tab to move focus between then
- then try hitting Enter or Space
Your ass just got scrolled all the way down the page didn’t you? Turns out YOU’RE the asshole.
Another common mistake is using the wrong interactive element. A good rule of thumb is:
- If it navigates, it’s a link. If it does something, it’s a button.
Links aren’t just styled text. They can open in new tab, ⌘Cmd+click, copy link address, status bar preview, and the browser can prefetch them.
An <a href="#" onClick={...}> throws all of that away and breaks the back button as a bonus. A <button> that navigates does the opposite: it looks actionable but can’t be opened in a new tab, previewed or prefetched.
I originally posted the “puppy dies” line in a Slack channel, and a coworker replied:
Yep! eslint-plugin-jsx-a11y has rules
for exactly this that will will all flag a click handler on a div at build time:
no-noninteractive-element-interactionsno-static-element-interactions,click-events-have-key-events
That is until someone breaks out one of these bad boys:
<Box onClick={...}>Every design-system wrapper, every styled.div, every polymorphic <Card> is invisible to the rule. You can plug part of the hole by telling the plugin what your components render:
// eslint configsettings: { 'jsx-a11y': { components: { Box: 'div', Card: 'div', TextField: 'input', }, },},But it has to be maintained by hand. So the linter is a safety net, not a substitute for knowing the rule yourself.
If a human is meant to interact with it, it’s a <button>, an <a>, or a form control (e.g. <input>, <select>, etc.). The div is for layout1.
Follow that and you get keyboard support, screen reader semantics, and voice control compatibility for free…and somewhere out there, a puppy lives.
-
A problem that comes up when building complex interfaces is that you can’t nest interactive elements. That means no links inside buttons, or visa versa, also no nested buttons. So when you want the whole card to be clickable but also have buttons inside, it, the best solution is usually absolutely positioning the link within a wrapper div to take up the whole thing visually, but be a child in the DOM tree, combined with effective use of
pointer-events: none;. ↩