Semantics Are Behaviour
Choosing an element selects a role, a place in the tab order, a set of keyboard defaults, an activation behaviour and an entry in the accessibility tree. div selects none of them.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What does the browser actually do differently when I write button instead of div?
A person wants to trigger an action — save, delete, expand — with whatever input device they have to hand, and to be told what the control is before they commit to using it.
HTML is markup for content; behaviour is JavaScript's job. Any element with a click handler is a button, and div is the neutral choice because it arrives with no styles to fight.
A div is not in the tab order. A keyboard user reaches every other control on the page and skips straight past this one, with no indication that anything was there.
- A
divis not in the tab order. A keyboard user reaches every other control on the page and skips straight past this one, with no indication that anything was there. - Enter and Space do nothing, because the browser only synthesises a click from those keys for elements that have an activation behaviour. A
clickhandler on adivfires for a mouse and for a touch tap and for nothing else. - A screen reader announces the text and no role. The user hears "Save" the way they hear any other paragraph, and the control does not appear in the rotor list of buttons they use to find controls quickly.
- Inside a form, a
divsubmits nothing and participates in nothing. The Enter-key-in-a-text-field submission every user has been trained on for thirty years silently stops working (Submission: Method, Encoding and Doing It Once). - An
<a onclick>without anhrefbreaks middle-click, Cmd/Ctrl-click, "open in new tab", "copy link address" and the browser's own link list — behaviours a user attributes to the browser, so its absence reads as the site being broken. - Windows high contrast and forced-colors mode restyle native controls and leave a hand-built one as unstyled text on an unexpected background, because the browser has no idea it was a control.
What is actually happening
In the browser, not in the framework.
- The parser maps each tag name to a specific element interface with defined behaviour —
HTMLButtonElement,HTMLAnchorElement,HTMLInputElement. That mapping is what carries everything below; it is not decoration on top of a generic node (The DOM Is Not Your HTML). - The browser computes an accessibility tree from the DOM: a role, an accessible name, a description and a set of states per exposed node. Implicit roles come from the tag; explicit ones from ARIA. A
divhas no implicit role, so it produces a generic node with no name (The Accessibility Tree). - Focusability is a property of the element type.
button,a[href],input,select,textarea,summaryand a handful of others are in the sequential focus order by default; everything else needstabindex="0"to join it (Keyboard Operability). - Activation behaviour is a spec concept, not a convention. Dispatching a
clickon abuttonruns the element's activation behaviour afterwards unless the event was cancelled — and Enter or Space on a focused button dispatch thatclickfor you (preventDefault vs stopPropagation). - The user-agent stylesheet supplies a default appearance, and native controls additionally carry an internal shadow tree and a platform-drawn appearance that responds to OS settings — high contrast, reduced motion, accent colour (Contrast, Colour and Motion).
- Non-assistive tooling reads the same tags: reader modes look for
article, translation tools segment by block element, password managers look forinput[type=password]andautocomplete, and test runners can query by role. Semantics are a machine-readable contract with more consumers than you have users.
What this makes the browser do
And which of it is avoidable.
- Computing a role and an accessible name for every exposed node, and recomputing the affected subtree whenever a relevant attribute, label relationship or text node changes. Name computation walks references —
aria-labelledbychains cost more than a plain label. - Applying user-agent styles and, for native controls, drawing a platform appearance. This is genuinely cheaper than the several elements plus JavaScript that replace it, but it is not zero.
- Every extra wrapper element added to avoid a semantic one is another node to match selectors against, compute style for and lay out (Selector Matching Cost).
- The avoidable work is the JavaScript: a hand-built button ships listener registration, key handling and state management that a native
buttonperforms in C++ before your bundle has parsed (The Real Cost of JavaScript).
What a button gives you, itemised
The clearest way to see the cost of the wrong element is to write down the specification of the right one. Everything below is behaviour the browser implements for <button> before your application code exists, and every line of it becomes your responsibility the moment you choose a div instead.
Read the breaks field at the bottom of this spec as the reason the pattern is worth stating at all: the mistake is not usually skipping the whole list, it is implementing four of the six items and shipping something that is now harder to diagnose than a plain unlabelled div would have been.
semantics <button type="button"> — implicit role button, accessible name from its text content, disabled exposed as a state rather than only as a colour.
| Tab / Shift+Tab | Moves focus to and from the control in DOM order, with no tabindex needed. |
| Enter | Dispatches a click event and runs the activation behaviour. |
| Space | Also dispatches a click — on key-up, which is why holding Space does not repeat-fire. |
| Escape | Nothing here, which is correct: the button does not swallow a key the surrounding dialog or menu may need. |
- — Focusable by default; removed from the focus order automatically when
disabled. - — Draws a platform focus indicator that respects the user's contrast and colour settings, and matches
:focus-visibleonly when the user is navigating by keyboard. - — Focus does not move on click in every engine — do not build behaviour that assumes it did.
- — Role and name together: the equivalent of "Save, button".
- — Disabled state, without needing
aria-disabled. - — Appears in the screen reader's list of buttons, and under the swipe-by-control-type gestures on mobile.
usually broken by Rebuilding this on a div and stopping at role="button" plus a click handler. That produces a control that is announced as a button, is not in the tab order, and cannot be activated by any key at all — a defect that automated scanners often report as a pass because a role and a name are both present.
The div that wants to be a button
Written out, the replacement is not shorter. It is longer, it ships JavaScript, it is inert until that JavaScript runs, and it still does not respond to forced-colors mode or participate in a form.
Note the keydown handler in particular. Native buttons fire on Space at key-up and links fire on Enter; a single handler that treats both keys identically on key-down is close but audibly wrong to anyone who uses the keyboard as their primary input — held Space repeats, and the activation happens a beat earlier than everywhere else in the operating system.
1<!-- What people write -->2<div class="btn" onclick="save()">Save</div>3 4<!-- What it would take to approximate a button -->5<div6 class="btn"7 role="button"8 tabindex="0"9 aria-disabled="false"10 onclick="save()"11 onkeydown="if (event.key === 'Enter') { event.preventDefault(); save() }"12 onkeyup="if (event.key === ' ') { event.preventDefault(); save() }"13>Save</div>14 15<!-- What the platform already implements -->16<button type="button" class="btn" onclick="save()">Save</button>The approximation is still missing form participation, the disabled state, the forced-colors appearance, and the fact that it does nothing at all until its script has run. Handlers are inline here to keep the comparison in one file, not as a recommendation — a real page attaches these in script and a Content-Security-Policy without unsafe-inline will block them (Content Security Policy).
Picking the element
Most of the decisions in this domain are genuinely contested. This one mostly is not: for each behaviour there is an element that already implements it, and the interesting question is only which behaviour you actually want. The common wrong choices in the last column are the ones that survive review because they look correct in a browser driven by a mouse.
The article row is worth a second look. It is often reached for as "a card", and it is not a card — it is a self-contained composition that would still make sense syndicated somewhere else. Using it for a layout unit adds a mapped role to the accessibility tree and makes the page noisier to navigate, which is a reminder that the wrong semantic element is a real cost and not a free upgrade over div (Div Soup: How It Happens and What It Costs).
| Behaviour you want | Element | What it brings that a div does not | The common wrong choice |
|---|---|---|---|
| Something happens on this page | button | Tab order, Enter and Space activation, button role, disabled state, form participation | div with a click handler, or a href="#" |
| The address changes | a href | Link role, Enter activation, modifier-click, open in new tab, copy link, browser history | div calling a router, which discards every one of those |
| A group of related controls | fieldset + legend | Group semantics, a name announced once for the whole group | A styled div with a heading that assistive technology never associates |
| A list of things | ul / ol + li | Item count announced up front, list navigation commands, and a signal about ordering | div per row, which loses "list, 12 items" entirely |
| Tabular data | table with th and scope | Row and column headers announced with each cell, table navigation commands | A CSS grid of divs, which is unreadable cell by cell |
| Show and hide a region | details + summary | Expanded state, keyboard toggle, find-in-page opening it, no JavaScript (What Native Elements Already Do) | A div pair plus a boolean, plus a bug where the state is not announced |
| A self-contained composition | article | A document role reader modes and syndication tools understand | Using it for every card, which fills the accessibility tree with noise |
| A box for layout only | div | Nothing — which is exactly right when nothing is what you want | Adding a role or a tabindex to it "just in case" |
How to build it
Most important first.
- Choose the element by the behaviour you want, then style it. The question is never "what does this look like" but "what should happen when someone activates it with a keyboard, a mouse, a screen reader and a voice command".
buttonfor something that happens on this page;awith a realhreffor something that changes the address. The test that settles almost every argument: would "open in a new tab" be a sensible thing for a user to try?- Reset the appearance instead of avoiding the element.
all: unsetor a small button reset gives you a blank canvas with the behaviour intact, which is strictly more than adivgives you. - Prefer implicit semantics to ARIA. ARIA changes only how the accessibility tree describes a node — it adds no focus behaviour, no key handling and no activation behaviour (Semantics Before ARIA).
- If you genuinely must build on a
div— because the native element cannot be shaped the way the product needs — write down the whole specification first: role, tabindex, key handlers, disabled semantics, focus visibility. Then accept that forced-colors and platform conventions will still not be right (Accessible Component Patterns).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- The role tells the user what kind of thing this is; the accessible name tells them which one; the state tells them what it is doing now. A
divsupplies none of the three, and no amount of styling adds them. - Keyboard defaults are not a nicety layered on top: Enter and Space on a button, Enter on a link, arrow keys within a radio group, Escape to dismiss. Users bring these from every other application, and a hand-built control has to earn each one back.
- Screen-reader users navigate by element type — next button, next link, next heading, next form field. A control that is a generic node is not in any of those lists, which makes it effectively invisible even to a user who is reading the whole page.
- Forced-colors and high-contrast modes restyle native controls automatically. A custom control keeps whatever colours you gave it, including colours the user explicitly asked the operating system to override.
- Visible text and accessible name should match, and the visible text should come first. This is what makes speech control work, and it is the accessibility requirement most often broken by an icon button whose label lives only in an
aria-label(The Rules of ARIA).
What can go wrong
role="button"with notabindex: the control is now announced as a button and is still unreachable by keyboard. The mitigation made the bug harder to notice.tabindex="0"with no key handler: focusable, announced, and completely inert when activated. Screen-reader users get the worst version — a control they can find and cannot use.- Handling
keydownfor Enter only. Native buttons activate on Space; links activate on Enter. Getting this subtly wrong is more confusing than not handling keys at all. disabledstyling withoutdisabledoraria-disabledsemantics: the control looks unavailable and is still activated by keyboard and by script.- Nested interactive elements — a
buttoninside ana, or a link inside a link. The parse result differs from what you wrote and the accessibility tree is unpredictable. outline: noneon the focus state, which removes the only indication a keyboard user has of where they are.:focus-visibleexists precisely so you can restyle it rather than delete it.- An
aria-labelthat disagrees with the visible text. Voice-control users say what they see; if the accessible name is different, the command does nothing and they have no way to discover why.
- Semantics are never an authorization boundary. A
disabledbutton, a hidden control and a missing menu item are all UI statements about what the server should allow, not enforcement of it (Authorization-Aware UI). - An
hrefbuilt from user-supplied data is an injection sink: ajavascript:URL in anhrefexecutes with the page's full authority. Validate the scheme, do not merely escape the string (Cross-Site Scripting). target="_blank"historically gave the opened document a handle back to yours viawindow.opener. Modern browsers implyrel="noopener", but that is a default that older engines and some embedded webviews do not share — set it explicitly.- Building semantic markup with
innerHTMLfrom untrusted content hands the attacker the same element vocabulary you are using, includingform,iframeand event-handler attributes (Sanitization and Trusted HTML).
- "
role="button"makes it a button." It changes one thing: what the accessibility tree says. Focus, keyboard activation, form participation and forced-colors behaviour are all untouched. - "An
aria-labelfixes an unlabelled control." It supplies a name for a node that may still have no role, no focus and no activation. A name on a generic node is a better-described nothing. - "Semantic HTML is an SEO thing." Crawlers are one consumer out of many, and the ones that matter most to your users are the accessibility tree and the keyboard.
- "
divis neutral, so it is the safe default." It is the element with the fewest behaviours, which makes it the *most* expensive default for anything interactive — every removed behaviour is one you now owe. - "We will make it accessible after it works." It does not work; it works for a pointer. That is a subset of users, discovered late (Semantics Before ARIA).
Measuring it, and what changes in the field
- The Elements panel's accessibility pane shows the computed role, name, state and the accessibility tree node for whatever is selected. If the role is "generic", you have your answer (A Mental Model of the Devtools).
- Tab through the interface with your hands off the mouse. It takes a minute, requires no tooling, and catches the majority of these defects before any automated check runs.
- Automated scanners — axe, Lighthouse — reliably catch missing names and unreachable controls, and cannot judge whether a name is the *right* name or whether the keyboard behaviour matches the visible pattern (Accessibility Testing).
- Tests that query by role rather than by test id fail loudly when semantics regress, which turns an invisible defect into a red build (Component Testing).
- On a slow device the difference is larger than it looks: the native button works during parsing, and the hand-built one does nothing until its JavaScript has downloaded, parsed and executed (Hydration).
- On mobile screen readers, navigation is gesture-based and leans even harder on roles — the rotor and the swipe-by-type gestures are the primary way users move, and a generic node is unreachable by both.
- Under forced-colors, reduced-motion or increased-contrast settings, native controls adapt and custom ones do not, so the gap widens exactly for the users who set those preferences.
- In an older or less common assistive technology, ARIA support is patchier than native element support. Implicit semantics degrade better than explicit ones.
- Native elements bring a user-agent appearance you must reset, and a few of them —
select,input[type=file],input[type=date],progress— remain genuinely hard to style consistently across engines. That is a real cost, and it is the one legitimate reason to build a composite widget. - Some designs cannot be expressed with a native element at all: a combobox with rich option content, a multi-select with tokens, a tree grid. Those need ARIA composites, which means owning the keyboard specification yourself, forever.
- Semantic elements sometimes carry layout behaviour you did not ask for —
ulhas list styling,tablehas table layout,fieldsethas a border and historically resisted flex. Resetting is cheap; discovering it late in a design review is not.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe mapping from HTML element to implicit ARIA role is specified in HTML-AAM and implemented by Blink, Gecko and WebKit alike; focusability defaults and activation behaviour come from the HTML specification rather than from any one engine.
- PLATFORM-SPECIFICHow a role is spoken is decided by the assistive technology and the platform accessibility API, not by the browser: NVDA on Windows, VoiceOver on macOS and TalkBack on Android word the same button differently and expose different quick-navigation gestures. Write to the role, never to a remembered phrase from one screen reader.
- BROWSER-SPECIFICWhich elements are reachable by Tab has historically differed: Safari on macOS excluded links and radio buttons from the tab order unless Full Keyboard Access was enabled, so a keyboard test that passes in Chrome can still leave Safari users stranded. Test the tab order in more than one browser.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — why a test suite that queries by test id can be fully green while every control in the product is unreachable by keyboard, and what a role-based query proves that a selector-based one cannot.