The absolute minimum that every developer should know about HTML interactive elements

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 an href)
  • <button>
  • <input>, <select>, <textarea>, <label>
  • <details> / <summary>
  • <audio> / <video> (with controls)
  • <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-visible styles, disabled semantics, 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-interactions
  • no-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 config
settings: {
'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.


  1. 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).