What you give up when you use a div instead of a button
A clickable div and a real button look identical. Here is every behavior the browser stops handing you, and what it costs to write back.
<div class="btn" onclick="save()">Save</div>
That line passes design review. It has the padding and the hover state, the cursor turns into a pointer, and in a screenshot you can’t tell it from the real thing.
Tab to it and nothing happens. The browser never offers it focus, so the keyboard never reaches it, so the handler hanging off it may as well not exist for anyone who isn’t holding a mouse.
Nothing about the line looks wrong. That’s why it survives review.
A button is a handful of behaviors wearing some CSS
Styling is the part <button> does worst and the part everyone notices. Every
browser ships an opinionated default that nobody keeps, so the first thing a
design system does is reset it, and once the reset lands a <div> and a
<button> are two identical rectangles.
That’s the trap. The visible difference is zero and the behavioral difference is most of what the element is for.
It’s focusable without a tabindex. It sits in the tab order at its position
in the DOM, which means the sequence stays correct when the layout moves, nobody
maintains a number, and nobody ever has to discover that a tabindex="3" left
somewhere in a modal has been quietly reordering the whole page.
Enter and Space activate it, and activation dispatches a real click event.
The handler you wrote for the mouse already serves the keyboard. There’s no
second code path to keep in sync, which is the part that matters six months
later when the handler changes.
Screen readers call it a button, because <button> carries an implicit
role="button". Its text content becomes the accessible name at no cost.
disabled does three things at once: blocks the click, drops the element out
of the tab order, and exposes the state to assistive technology. On a <div>
that attribute is inert. It isn’t global, so the browser ignores it entirely and
your [disabled] CSS selector ends up being the only thing in the system that
responds to it, which looks correct and is the exact shape of the bug.
Inside a <form>, it submits. <button> defaults to type="submit". Free
behavior or a surprise bug, depending on whether you knew. If a React form has
ever reloaded the page on you, that was this.
Watch which events each control actually gets
Three controls below. One CSS class between them, so they’re the same rectangle, and the log records every event each one receives.
Try it
Put your mouse down. Press Tab until focus enters the demo, then keep tabbing, and press Enter and Space on whatever you land on. The log records every event the controls receive.
1 — bare div
<div> plus a click listener.
2 — div with ARIA
Plus role, tabindex, and a keydown handler.
3 — real button
No role, no tabindex.
Focus: somewhere else on the page
Event log — newest first
- Nothing yet.
The first thing to try is the boring one: press Tab and count how many stops
there are. Panel 1 is not among them, and no amount of :hover styling changes
that.
The more interesting result is on panel 3. Enter and Space don’t activate a
native button the same way. Enter fires on the way down; Space waits until you
let go. The log shows keydown Enter followed immediately by click, and then
keydown Space, keyup Space, click in that order.
That split is user-agent behavior rather than something the HTML spec pins down keystroke by keystroke, so trust the log in front of you over this paragraph. I haven’t checked it in Safari.
The ARIA version is longer than the button and still isn’t equal to it
Panel 2 is what people write when they can’t change the tag:
<div class="btn" role="button" tabindex="0">Save</div>
control.addEventListener("keydown", (event) => {
if (event.key !== "Enter" && event.key !== " ") return;
// Without this, Space scrolls the page instead of pressing anything.
if (event.key === " ") event.preventDefault();
control.click();
});
event.key for the space bar is " ". A single space character. "Space" is
the value of event.code, not event.key, and event.key === "Space" is a
check that compiles, reads correctly, and never once passes.
Two attributes and five lines of JavaScript, and it’s still behind. disabled
does nothing to it. It won’t submit a form. And the timing is wrong in a way you
can see in the log: this version fires both keys on keydown, so Space activates
on the way down here and on the way up on the real button.
You could fix that by splitting the handler across keydown and keyup. At that
point you’ve written more code to imitate a button than the button costs, and you
own every one of those lines the next time the behavior changes.
Where I’d still use a div
The case that actually holds up is a click target wrapping block content. Think
of a card: an image, a heading, two lines of summary, a tag list, and a “Read
more” link at the bottom, with the whole rectangle clickable. <button> takes
phrasing content and must not contain interactive content, so the moment that
link exists the card can’t be a button. Wanting it to be doesn’t change the
content model.
The fix there still isn’t role="button" on the card. Put a real <button> or
<a> on the title and stretch it over the card with an absolutely positioned
::after. The accessible name comes from the title text, the tab order is one
stop instead of three, and everything in this post that the platform gives you
for free still applies.
It costs you text selection across the card, because the overlay eats the drag. That’s a genuine loss and the reason I’d call this a trade rather than a fix.