Concepts — HTML as an Application Platform
Phase 1 · Platform substrate · Specification area §3 (semantic HTML, forms, native validation, dialog, popover, details/summary, tables, media, responsive images, progressive enhancement, custom elements, Shadow DOM). Feeds §12 (accessibility) and §26 (design systems).
0. Orientation
The 60-second version
HTML is not a template format. It is a declarative application platform, and every element in it is a bundle of behaviour the browser implements and maintains on your behalf: semantics, focus management, keyboard interaction, state, validation, and exposure to assistive technology.
The industry default is to discard that bundle and rebuild it in JavaScript. This module measures what that costs, and the results run consistently against intuition:
- Div soup is bigger than semantic HTML. 1.38× the bytes — while exposing zero landmarks, zero headings, and zero interactive elements. It is not a shortcut; it is more markup for less function.
- The native modal is smaller than the broken custom one.
<dialog>+showModal()passes 7/7 behavioural tests in 222 bytes. The naive custom modal passes 1/7 in 241 bytes — more code, less behaviour. - The good custom modal costs 5.4× and still misses one behaviour — and you own it forever.
The reframe that matters
The choice between <button> and <div role="button" tabindex="0"> is not a markup choice. It is
an ownership boundary. Everything the native element does that you now do yourself becomes
code you maintain across new browser versions, new focusable element types, shadow DOM, iframes,
and four team changes.
You are not choosing a tag. You are choosing what you are on the hook for in three years.
What you should be able to do after this module
| Capability | Evidence you have it |
|---|---|
| Price a custom control | State what the native element does, and what you accept by replacing it |
| Read the real AX tree | Use Accessibility.getFullAXTree, not markup inspection, to verify what AT receives |
| Architect validation | Native constraints as source of truth, your presentation layer on top |
| Diagnose "works in test, fails in prod" | Recognise dirty-value-flag and synthetic-event semantics |
| Set a design-system default | Know this is the most expensive-to-reverse decision a system makes |
1. What is it
HTML is not a template format. It is a declarative application platform with built-in semantics, accessibility, focus management, validation, and state — most of which frontend teams reimplement in JavaScript, worse, at greater cost, and then maintain forever.
This module treats HTML as an engineering choice with measurable consequences, and asks the question the specification puts at the centre of the curriculum:
What problem existed? What does the platform provide? What abstraction was introduced? What trade-off did it make? When does the abstraction leak?
What a native element actually is
<button> is not "a box with default styles." Enumerating what the browser gives you makes the
ownership question concrete — this is the list you rebuild, item by item, when you choose a div:
| Capability | Where it comes from | What you must do without it |
|---|---|---|
role=button in the AX tree | Implicit role | role="button" |
| Accessible name from its text content | Name computation | aria-label, or hope |
| Focusable in DOM order | Implicit tabindex | tabindex="0" |
| Enter and Space activate | UA behaviour | Two keydown handlers, and know Space fires on keyup |
disabled blocks focus, click, and form submit | Element semantics | aria-disabled + manual guards in every handler |
Participates in forms (type=submit) | Form association | Manual submit wiring |
| Reachable by voice control ("click Export") | Derived from role + name | Nothing you can do — voice control targets roles |
| Works with switch access, screen-reader forms mode | Derived from role | Nothing you can do |
| Focus ring matching platform conventions | UA stylesheet + :focus-visible | Reimplement, including the visible/not-visible heuristic |
The last three rows are the important ones: they are not reproducible with ARIA. Voice control
and switch access navigate by role, and a div with role="button" gets partway there — but only
if you also remembered the name, the tabindex, and both key handlers.
The three-layer model
Every question in this module resolves to which of three layers you are talking about:
DOM tree what your markup literally is
│
├── + CSS (display:none, visibility, order, content)
├── + ARIA (role, state, relationship overrides)
▼
ACCESSIBILITY TREE what assistive technology actually receives
│
▼
AT / voice control / switch access / browser UI
Two consequences engineers routinely miss:
- CSS changes the accessibility tree.
display: noneandvisibility: hiddenremove nodes from it.orderin flexbox does not change it — which is why reordering with CSS creates a mismatch between visual and focus order, a real and common accessibility bug. - ARIA only edits the middle arrow. It rewrites what the tree says. It cannot add focusability, keyboard handling, or state management, because those live in the element, not in the tree.
2. Why it matters
The platform version is usually smaller and more capable. Measured here: a <dialog> with
showModal() passes 7/7 behavioural tests in 222 bytes of JS. A naive custom modal passes
1/7 in more bytes. A conscientious hand-rolled implementation reaches 6/7 in 5.4× the
code — and owes maintenance forever.
Div soup costs more markup than semantic HTML and delivers nothing. Measured: div soup was 1.38× the bytes of the semantic version while exposing zero landmarks, zero headings and zero interactive elements to the accessibility tree. It is not a shortcut; it is more work for less function.
ARIA is a repair kit, not a substitute. Reconstructing the same accessibility tree from divs
took 1.69× the markup, required explicit tabindex to restore keyboard access that semantic
elements had for free, and only covers what you remembered to declare.
This is where accessibility becomes architecture rather than remediation. A team that reaches
for <div role="button" tabindex="0"> has taken on focus, keyboard activation, and state
announcement as permanent maintenance. A team that writes <button> has not. That decision is made
in Phase 1, and everything in fe-24 either inherits it or fights it.
Why automated audits will not save you
The div-soup variant exposes zero controls, zero landmarks and zero headings — and would pass most automated accessibility checks, because contrast checkers and alt-text linters do not flag "this should have been a button."
That gap is the entire reason this belongs in Phase 1 as an architecture module rather than in a compliance checklist. Automation catches attribute-level mistakes. It cannot catch a structural decision to opt out of the platform, and structural decisions are the expensive ones.
3. How it works
+==================================================================+
| MARKUP -> ACCESSIBILITY TREE |
+==================================================================+
<button>Export</button>
|
v
role=button, name="Export", focusable, Enter/Space activate,
announced as a button, exposed in the controls list
|
v
screen reader / voice control / switch access / browser UI
<div class="btn" onclick>Export</div>
|
v
role=generic, no name, NOT focusable, no keyboard activation,
invisible to every assistive navigation mode
3.1 Measured: three implementations of one UI
Identical visual result. Accessibility trees read from Accessibility.getFullAXTree — the
browser's real computed tree, not inferred from the markup.
| AX nodes | named | landmarks | headings | interactive | table roles | focusable | markup | |
|---|---|---|---|---|---|---|---|---|
| semantic HTML | 56 | 38 | 4 | 2 | 3 | 7 | 3 | 1.00× (459 B) |
| div soup | 46 | 24 | 0 | 0 | 0 | 0 | 0 | 1.38× (634 B) |
| div + ARIA | 48 | 33 | 4 | 2 | 3 | 7 | 3 | 1.69× (778 B) |
Read the columns in this order:
The zeros. Landmarks, headings and interactive elements are not cosmetic — they are the navigation modes assistive technology offers. A screen-reader user navigates by heading, jumps by landmark, and lists the controls on a page. Zero in those columns does not mean "harder to use"; it means those navigation modes do not exist on this page.
The byte counts. Div soup is 38% more markup. The "semantic HTML is verbose" belief is
measurably backwards, because a <nav> is shorter than a <div class="nav"> and a <button> is
shorter than a <div class="btn" tabindex="0" role="button">.
The focusable column in the ARIA row. ARIA reconstructed every role and landmark — and needed
explicit tabindex to get focusability back. That column is the proof of the "ARIA describes,
elements behave" rule, in one number.
3.2 Measured: dialog behaviours, driven with real keyboard input
| behaviour | <dialog> showModal() | custom, naive | custom, careful |
|---|---|---|---|
| opens | yes | yes | yes |
| focus moves in | yes | NO | yes |
| focus trapped (8 × Tab) | yes | NO | yes |
| background inert | yes | NO | yes |
| Escape closes | yes | NO | yes |
| focus restored to trigger | yes | NO | yes |
::backdrop available | yes | NO | NO |
| JS bytes | 222 | 241 | 1200 (5.4×) |
The naive custom modal costs more bytes than native and passes 1 of 7 — and it is exactly
what "just use a div with role="dialog"" produces in practice. This is the common case, not a
strawman.
The careful version is a good-faith implementation with focus trap, inert, Escape and focus
restore. It reaches 6/7 at 5.4× the code, and it must keep working across new focusable element
types, shadow DOM, iframes, and browser changes — forever. That maintenance tail is the real
number, and it does not appear in any byte count.
What showModal() gives you that is genuinely hard to rebuild: the top layer. A modal in the
top layer escapes all ancestor clipping, overflow: hidden, z-index stacking and transform
containing blocks. Every "my modal is cut off inside a scroll container" bug is a custom modal that
is not in the top layer, and the usual fix — portalling to document.body — is reimplementing what
the top layer does natively.
3.3 Measured: native constraint validation, zero lines of validation JS
| field | constraint | value | valid | ValidityState flag |
|---|---|---|---|---|
type=email | "not-an-email" | NO | typeMismatch | |
type=email | "a@b.co" | yes | — | |
| age | min=18 | "9" | NO | rangeUnderflow |
| age | min=18 | "30" | yes | — |
| code | pattern | "abc-12" | NO | patternMismatch |
| code | pattern | "ABC-1234" | yes | — |
| site | type=url | "nope" | NO | typeMismatch |
| site | type=url | "https://x.dev" | yes | — |
Submission blocked while invalid: yes. Allowed once valid: yes. :user-invalid supported:
yes — and it is the one you want, because :invalid matches an untouched empty required field
and paints an error on first paint.
What native validation does not do — and this list is the actual work:
- inline error text positioned in your layout (the native bubble is unstyleable)
aria-describedbywiring between input and error- localisation — browser messages follow browser locale, not application locale
- cross-field rules (confirm password, end-date after start-date)
- server-side validation, which is the only one that is a security control
So: reimplementing the constraints is the mistake; building the presentation is the work.
3.4 The dirty value flag — a trap that breaks test suites
| how the value was set | valid | tooShort |
|---|---|---|
el.value = 'short' (programmatic) | true | false |
user types "short" (real keystrokes) | false | true |
minlength/maxlength apply only to values the user edited — the HTML spec's dirty value
flag, set when the user (not a script) modifies a control. A test that sets .value directly never
sees tooShort and passes while the real form rejects the same input.
This is the same class of finding as fe-01's .click() result: synthetic interaction and real
interaction have different semantics, and the test harness is the thing that is wrong. Two
findings in two different modules, same root cause — which is why "drive the real dispatch path"
(user-event, Playwright, real keystrokes) is a standard rather than a preference.
4. Core terminology
| Term | Definition |
|---|---|
| Accessibility tree | The browser-computed tree exposed to assistive technology; derived from DOM + CSS + ARIA |
| Accessible name | The computed label for an element (from content, aria-label, <label>, etc.) |
| Name computation | The spec'd algorithm resolving an accessible name; why aria-label silently overrides visible text |
| Landmark | A navigable region: banner, navigation, main, contentinfo, complementary |
| Implicit role | The ARIA role an element has by virtue of being that element |
| Progressive enhancement | Working baseline in HTML, improved by CSS and JS rather than depending on them |
<dialog> / showModal() | Native modal with focus trap, inertness, ::backdrop, Escape handling |
| Top layer | Rendering layer above all content; escapes clipping, overflow, z-index and transform containing blocks |
inert | Attribute removing a subtree from focus, hit-testing and the accessibility tree |
| Popover API | popover + popovertarget: top-layer, light-dismiss, no JS positioning state |
| Constraint validation | Native required/type/pattern/min/max/minlength + ValidityState |
ValidityState | Per-input object of precise failure reasons (typeMismatch, rangeUnderflow, …) |
| Dirty value flag | HTML-spec flag set when the user edits a field; gates minlength/maxlength |
:user-invalid | Matches only after user interaction — the selector you usually want, not :invalid |
form.noValidate | Set from JS: suppresses the native bubble while keeping constraints and checkValidity() |
:focus-visible | Focus ring only when the UA judges it useful — the correct replacement for removing outlines |
| Shadow DOM | Encapsulated subtree with scoped styles; affects the accessibility tree and focus traversal |
delegatesFocus | Shadow-root option forwarding focus to the first focusable descendant |
::part() | Explicitly exposed shadow internals, stylable from outside |
5. Mental models
Native elements are behaviour bundles, not tags. <button> is not "a styled box". It is:
focusable, Enter/Space activation, role=button, accessible name from content, participates in
forms, respects disabled, exposed to voice control, works with switch access. Choosing a div
opts out of all of it simultaneously, and each piece must be rebuilt and maintained separately.
ARIA's first rule is "don't use ARIA". Not because ARIA is bad, but because it only changes
the accessibility tree. It does not add focusability, keyboard handling, or state management —
measured above: the ARIA variant needed explicit tabindex to get 3 focusable elements, and still
has no keyboard activation behaviour. ARIA describes; elements behave.
Every ARIA attribute is a manual invariant, and manual invariants decay. aria-expanded on a
div is state you must remember to update in every code path — including the one added next quarter
by someone who has not read this. <details>/<summary> cannot drift, because the browser owns
the state. Prefer mechanisms that cannot get out of sync over mechanisms you must keep in sync.
The platform's abstraction cost is the styling boundary. Native elements trade customisation for
behaviour, and the honest version of "we can't use <dialog>" is usually "we couldn't style the
backdrop the way the design asked." That is a real cost — and it should be traded explicitly
against 5.4× the code and permanent ownership, not assumed away.
Keep the element, restyle it. The pattern that resolves most of those styling conflicts:
appearance: none on a real <input type="checkbox"> gives you complete visual control while
retaining focus, keyboard, form participation and accessibility. Replacing it with a div gives you
the same visuals and none of that. The instinct "the native element won't look right, so I'll build
my own" skips a step that usually works.
Progressive enhancement is a failure-mode question, not an ideology. The question is never "should this work without JS" in the abstract, but "what does the user see when the bundle fails, the CDN is blocked, or the script throws during hydration?" A form built on native constraints degrades to a working form. A form built on a JS validation library degrades to nothing.
6. Applied: the decisions this module actually makes
6.1 When a custom control is the right call
Native elements are the default, not a rule. The honest cases for replacing one:
| Situation | Why native loses | What you are accepting |
|---|---|---|
| Design requires styling the browser refuses | ::backdrop, <select> internals, validation bubbles | permanent behaviour maintenance |
| Behaviour genuinely differs | multi-select combobox, virtualised listbox, rich text | you now own a WAI-ARIA pattern end to end |
| Cross-browser inconsistency is unacceptable | date/time inputs vary widely between engines | you own date semantics, i18n and keyboard |
| The element does not exist | tree grid, split button, complex data grid | the full APG pattern, tested with real AT |
The decision rule: you are not choosing markup, you are choosing an ownership boundary. The question is not "can we build this?" but "will this still be correct in three years, after four team changes, a framework upgrade, and a new browser focus behaviour?"
Measured cost of that ownership for the simplest possible case — a modal — is 5.4× the code and still one behaviour short. A combobox is an order of magnitude beyond that.
6.2 The styling boundary, and the modern escape hatches
Almost every "we can't use the native element" reduces to styling. That is a legitimate constraint, and the platform has been closing the gap:
| Want | Modern option |
|---|---|
| styled modal backdrop | dialog::backdrop |
| light-dismiss overlays without JS state | Popover API (popover, popovertarget) |
styled <select> | appearance: base-select (emerging; check support before relying) |
| styled checkbox/radio | accent-color, or appearance:none + custom visuals on the real input |
| positioned anchored UI | CSS Anchor Positioning |
| exposed shadow internals | ::part() |
| focus ring only when useful | :focus-visible |
The pattern worth internalising: keep the native element and restyle it, rather than replacing it with a div.
6.3 Native validation as source of truth
The architecture that survives contact with designers and localisation:
<input id="email" name="email" type="email" required
aria-describedby="email-error">
<p id="email-error" role="alert" hidden></p>
// Constraints live in HTML. JS only presents them.
const show = (input, msgEl, messages) => {
const v = input.validity;
if (v.valid) { msgEl.hidden = true; return true; }
const key = Object.keys(messages).find(k => v[k]) ?? 'default';
msgEl.textContent = messages[key]; // YOUR locale, not the browser's
msgEl.hidden = false;
return false;
};
form.addEventListener('submit', (e) => { if (!form.checkValidity()) e.preventDefault(); /* … */ });
form.noValidate = true; // suppress the bubble, keep the constraints
Three properties this has and a validation library does not: constraints are visible in the markup
(so they are testable, server-mirrorable and greppable), the browser enforces them before your JS
loads, and ValidityState gives you precise reason codes rather than boolean failure.
form.noValidate = true set from JavaScript is the key detail — the constraints still evaluate
and checkValidity() still works; only the native bubble is suppressed. If JS fails to load, the
attribute is absent and native validation protects the form. That single line is progressive
enhancement made concrete.
6.4 Progressive enhancement as failure-mode analysis
Not an ideology. The question is what the user gets in each degraded state:
| Failure | Native-first form | JS-validation form |
|---|---|---|
| Bundle fails to load | works, validates, submits | inert inputs, no submit |
| JS throws during hydration | works | broken, often silently |
| Slow network, JS pending | usable immediately | visible but non-functional |
| Extension/CSP blocks a script | works | broken |
This is the concrete link into fe-20 (unhappy path). "Visible but non-functional" is the single most common production complaint about SPAs, and it is decided by choices made in this module.
6.5 Reviewing a diff — the seven smells
<div>withonClickand norole/tabindex. Invisible to every assistive navigation mode. The fix is<button>, not more attributes.<div role="button" tabindex="0">. Better, and still missing Enter/Space activation,disabledsemantics, and form participation. Ask what the native element cost.- A custom modal. Ask for the seven behaviours (§3.2) as tests. If they are not tested, they are not implemented.
outline: nonewithout:focus-visible. Removes keyboard usability for a visual preference.- A validation library reimplementing
required/type/pattern. Reimplementing the free part; ask whereValidityStateis read. :invalidused as the styling hook. Paints errors on untouched empty fields;:user-invalidis the intent.<Box as="button">or a styled-component factory. The element became a prop, so nobody reviews it. Ask what it actually renders.
6.6 Verifying, not assuming
- Read the real accessibility tree, not the markup.
Accessibility.getFullAXTreeover CDP, or DevTools' Accessibility pane. Markup inspection tells you what you wrote; the AX tree tells you what was received. - Test dialogs behaviourally with real keyboard input — Tab eight times and assert focus is still inside. A snapshot test proves nothing about a focus trap.
- Drive real dispatch paths. The dirty-value-flag result (§3.4) and
fe-01's.click()result are the same lesson:el.value = xandel.click()are not what users do. - Count landmarks, headings and controls as an assertion, not an impression. Those three numbers are the navigation modes; zero is a functional regression, not a style nit.
6.7 What breaks at scale
- Design systems calcify the choice. If a system ships
<Button>as a styleddiv, every consuming app inherits the liability, and fixing it later is a breaking change across dozens of teams. This is the highest-leverage decision a design system makes and it is usually made in week one, by one person, without measurement (fe-38). - ARIA drifts out of sync. Every ARIA attribute is a manual invariant.
<details>/<summary>cannot drift because the browser owns the state. - Framework abstractions hide element choice.
<Box as="button">makes the underlying element a prop, so auditing "what does this actually render" becomes archaeology. - Shadow DOM changes focus and accessibility traversal. Custom elements with shadow roots need
delegatesFocus, careful::partexposure, and explicit label association. Encapsulation is not free at the accessibility boundary — and a focus trap that walksquerySelectorAllwill not find focusable elements inside a shadow root, which is one reason the "careful" custom modal is never quite finished. - Automated checks pass on div soup. Contrast and alt-text checkers do not flag "this should have been a button", which is the limit of automation the specification asks us to teach (fe-24).
7. Common misconceptions
-
"Semantic HTML is verbose." Measured backwards: div soup was 1.38× the bytes of the semantic version, and ARIA-patched divs 1.69× — for the same visual result and (in the div soup case) none of the function.
-
"ARIA makes divs accessible." It makes them announced. Measured: the ARIA variant matched semantic HTML on roles and landmarks but required manual
tabindexfor focus, and still lacks Enter/Space activation,disabledsemantics, and form participation. -
"We need a custom modal for design reasons." Sometimes true. But the naive custom modal measured 1/7 on behaviour while costing more bytes than native, and the careful one costs 5.4× and still lacks
::backdrop. Make it an explicit trade, not a default. -
"Native form validation isn't good enough." It correctly enforced
type=email,type=url,pattern, andmin/max, blocked submission, and exposed preciseValidityStateflags — with zero lines of validation JS. What it does not do is presentation, localisation, and cross-field rules. The correct architecture is native constraints as the source of truth with your own presentation layer; reimplementing the constraints is the mistake. -
"
:invalidis the styling hook.":invalidmatches before the user has typed anything, so an empty required field is styled as an error on first paint.:user-invalidis the one you want, and it is supported. -
"Setting
.valuein a test is equivalent to typing." Measured false, and it matters:minlengthreportedvalid=true, tooShort=falsefor a programmatically-set short value andvalid=false, tooShort=truefor the same string typed.minlength/maxlengthare gated on the spec's dirty value flag. A test that sets.valuepasses while the real form rejects the input — the same class of bug as fe-01's.click()finding. -
"An automated audit passed, so we're accessible." The div-soup variant exposes zero controls, zero landmarks and zero headings, and would score well on most automated checks. Automation catches attribute mistakes, not structural ones.
-
"
aria-labelis a safe way to name things." It overrides visible text, so voice-control users saying the visible label may fail to activate the control, and screen-reader users hear something different from what they see. Prefer visible content as the name. -
"
display: noneandaria-hiddenandhiddenare interchangeable." They differ in whether the element is rendered, focusable, and present in the AX tree.aria-hidden="true"on a focusable element is the worst combination: reachable by keyboard, invisible to the screen reader announcing it. -
"Portalling to
document.bodyis the same as the top layer." It fixes clipping by escaping ancestors, but you still own stacking, inertness, Escape, and focus restore. The top layer gives all of that as a property of being in it.
8. Interview talking points
- "Div soup isn't a shortcut — we measured it at 1.38× the markup of semantic HTML with zero landmarks, zero headings and zero exposed controls. You pay more to get less, and those zeros aren't a usability degradation, they're navigation modes that don't exist on the page."
- "I treat
<div role=\"button\" tabindex=\"0\">as taking on permanent maintenance: focus, keyboard activation, disabled semantics, and state announcement all become yours.<button>is the same visual result with none of that liability." - "We tested a native
<dialog>against a hand-rolled one behaviourally — focus trap, inertness, Escape, focus restore. Native passed 7/7 in 222 bytes; the naive custom one passed 1/7 in more bytes; a careful implementation reached 6/7 at 5.4× the code. If design requires a custom one, fine, but that's the price and the maintenance is forever." - "Native constraint validation should be the source of truth, read through the ValidityState API,
with your own presentation layer. Teams reimplement the constraints, which is the one part they
get for free, and then still have to build the part that's actually missing — localisation,
positioning,
aria-describedbywiring, cross-field rules." - "A subtle one:
minlengthonly applies to user-edited values because of the dirty value flag. So a test that sets.valuedirectly never seestooShortand passes while the real form rejects the same input. That's the same shape as the.click()finding in scheduling — synthetic interaction has different semantics, and the harness is what's wrong." - "Every ARIA attribute is a manual invariant, and manual invariants decay.
aria-expandedon a div has to be updated in every code path including the one someone adds next quarter.<details>/<summary>can't drift because the browser owns the state." - "The most consequential thing a design system decides is whether its primitives wrap native elements or replace them, and it's usually decided in week one by one person without measurement. Reversing it later is a breaking change across every consuming team."
9. Connections to other modules
fe-01— the dirty-value-flag finding and the.click()finding are the same lesson about synthetic vs real interaction, arrived at from two different directions.fe-05/fe-06(CSS, layout) — the styling boundary is the real cost of native elements;::backdrop,::part,appearance, and form-control styling are where that trade is negotiated.fe-24(accessibility as engineering) — this module is its foundation. The specification's dependency is strict: ARIA-first accessibility built without semantic HTML produces compliant- looking, unusable interfaces.fe-20(unhappy path) — progressive enhancement is a failure-mode strategy; what the user gets when the bundle fails is decided by the choices in this module.fe-32/fe-33(testing) — the dirty-value-flag finding is a concrete case where synthetic interaction and real interaction have different semantics.fe-38(design systems) — whether a design system's primitives wrap native elements or replace them is the single most consequential decision it makes.bi-04-dom-internals— how the accessibility tree is computed from the DOM and style, from the Blink side.browser-framework-internals.md§8 (DOM Internals) — cross when you need to know how the accessibility tree is computed rather than what it contains.