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, span, li, svg, td.
A div with a click handler looks done. Mouse users click it, it works, the ticket gets closed.
But compared to a button, 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 a button as “Save, button”. It announces your div just as the text inside it. No hint that it’s clickable, no way to find it when jumping between controls.
- 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).
Don’t take my word for it — these two are styled identically:
Fake button (div): clicked 0×
With a mouse, they’re indistinguishable. Now put the mouse down: press Tab until something in this demo focuses, then hit Enter. Only one of them is reachable — and that’s the polite version of the story a screen reader would tell you.
The second most common sin is using the wrong interactive element. The 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 — users get an entire toolbox with them: 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.
If you genuinely cannot use a native element, ARIA lets you rebuild one. This is the minimum faithful reimplementation of a button:
<div role="button" tabIndex={0} onClick={handleClick} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() // stop Space from scrolling the page handleClick() } }}> Save</div>And it’s still missing disabled handling, form participation, and half a dozen subtler
behaviors. Compare with the alternative:
<button onClick={handleClick}>Save</button>This is why the first rule of ARIA is don’t use ARIA. If a native element already has the semantics you need, use the element.
When I posted the puppy hot take in a work Slack channel, 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
But there’s a hole: the linter only sees element names. It can’t know that this is a div wearing a trench coat:
<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', }, },},That mapping is worth setting up, 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. 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.
-
The one real constraint: buttons can’t contain other interactive elements; no links inside buttons, no buttons inside buttons. If your design has a card that’s clickable and has buttons inside it, that’s its own can of worms (search “nested interactive elements”, the usual fix is to overlay a single link/button rather than nest them). ↩