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

CapabilityEvidence you have it
Price a custom controlState what the native element does, and what you accept by replacing it
Read the real AX treeUse Accessibility.getFullAXTree, not markup inspection, to verify what AT receives
Architect validationNative 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 defaultKnow 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:

CapabilityWhere it comes fromWhat you must do without it
role=button in the AX treeImplicit rolerole="button"
Accessible name from its text contentName computationaria-label, or hope
Focusable in DOM orderImplicit tabindextabindex="0"
Enter and Space activateUA behaviourTwo keydown handlers, and know Space fires on keyup
disabled blocks focus, click, and form submitElement semanticsaria-disabled + manual guards in every handler
Participates in forms (type=submit)Form associationManual submit wiring
Reachable by voice control ("click Export")Derived from role + nameNothing you can do — voice control targets roles
Works with switch access, screen-reader forms modeDerived from roleNothing you can do
Focus ring matching platform conventionsUA stylesheet + :focus-visibleReimplement, 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: none and visibility: hidden remove nodes from it. order in 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 nodesnamedlandmarksheadingsinteractivetable rolesfocusablemarkup
semantic HTML5638423731.00× (459 B)
div soup4624000001.38× (634 B)
div + ARIA4833423731.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, naivecustom, careful
opensyesyesyes
focus moves inyesNOyes
focus trapped (8 × Tab)yesNOyes
background inertyesNOyes
Escape closesyesNOyes
focus restored to triggeryesNOyes
::backdrop availableyesNONO
JS bytes2222411200 (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

fieldconstraintvaluevalidValidityState flag
emailtype=email"not-an-email"NOtypeMismatch
emailtype=email"a@b.co"yes—
agemin=18"9"NOrangeUnderflow
agemin=18"30"yes—
codepattern"abc-12"NOpatternMismatch
codepattern"ABC-1234"yes—
sitetype=url"nope"NOtypeMismatch
sitetype=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-describedby wiring 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 setvalidtooShort
el.value = 'short' (programmatic)truefalse
user types "short" (real keystrokes)falsetrue

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

TermDefinition
Accessibility treeThe browser-computed tree exposed to assistive technology; derived from DOM + CSS + ARIA
Accessible nameThe computed label for an element (from content, aria-label, <label>, etc.)
Name computationThe spec'd algorithm resolving an accessible name; why aria-label silently overrides visible text
LandmarkA navigable region: banner, navigation, main, contentinfo, complementary
Implicit roleThe ARIA role an element has by virtue of being that element
Progressive enhancementWorking 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 layerRendering layer above all content; escapes clipping, overflow, z-index and transform containing blocks
inertAttribute removing a subtree from focus, hit-testing and the accessibility tree
Popover APIpopover + popovertarget: top-layer, light-dismiss, no JS positioning state
Constraint validationNative required/type/pattern/min/max/minlength + ValidityState
ValidityStatePer-input object of precise failure reasons (typeMismatch, rangeUnderflow, …)
Dirty value flagHTML-spec flag set when the user edits a field; gates minlength/maxlength
:user-invalidMatches only after user interaction — the selector you usually want, not :invalid
form.noValidateSet from JS: suppresses the native bubble while keeping constraints and checkValidity()
:focus-visibleFocus ring only when the UA judges it useful — the correct replacement for removing outlines
Shadow DOMEncapsulated subtree with scoped styles; affects the accessibility tree and focus traversal
delegatesFocusShadow-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:

SituationWhy native losesWhat you are accepting
Design requires styling the browser refuses::backdrop, <select> internals, validation bubblespermanent behaviour maintenance
Behaviour genuinely differsmulti-select combobox, virtualised listbox, rich textyou now own a WAI-ARIA pattern end to end
Cross-browser inconsistency is unacceptabledate/time inputs vary widely between enginesyou own date semantics, i18n and keyboard
The element does not existtree grid, split button, complex data gridthe 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:

WantModern option
styled modal backdropdialog::backdrop
light-dismiss overlays without JS statePopover API (popover, popovertarget)
styled <select>appearance: base-select (emerging; check support before relying)
styled checkbox/radioaccent-color, or appearance:none + custom visuals on the real input
positioned anchored UICSS 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:

FailureNative-first formJS-validation form
Bundle fails to loadworks, validates, submitsinert inputs, no submit
JS throws during hydrationworksbroken, often silently
Slow network, JS pendingusable immediatelyvisible but non-functional
Extension/CSP blocks a scriptworksbroken

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

  1. <div> with onClick and no role/tabindex. Invisible to every assistive navigation mode. The fix is <button>, not more attributes.
  2. <div role="button" tabindex="0">. Better, and still missing Enter/Space activation, disabled semantics, and form participation. Ask what the native element cost.
  3. A custom modal. Ask for the seven behaviours (§3.2) as tests. If they are not tested, they are not implemented.
  4. outline: none without :focus-visible. Removes keyboard usability for a visual preference.
  5. A validation library reimplementing required/type/pattern. Reimplementing the free part; ask where ValidityState is read.
  6. :invalid used as the styling hook. Paints errors on untouched empty fields; :user-invalid is the intent.
  7. <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.getFullAXTree over 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 = x and el.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 styled div, 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 ::part exposure, and explicit label association. Encapsulation is not free at the accessibility boundary — and a focus trap that walks querySelectorAll will 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

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

  2. "ARIA makes divs accessible." It makes them announced. Measured: the ARIA variant matched semantic HTML on roles and landmarks but required manual tabindex for focus, and still lacks Enter/Space activation, disabled semantics, and form participation.

  3. "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.

  4. "Native form validation isn't good enough." It correctly enforced type=email, type=url, pattern, and min/max, blocked submission, and exposed precise ValidityState flags — 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.

  5. ":invalid is the styling hook." :invalid matches before the user has typed anything, so an empty required field is styled as an error on first paint. :user-invalid is the one you want, and it is supported.

  6. "Setting .value in a test is equivalent to typing." Measured false, and it matters: minlength reported valid=true, tooShort=false for a programmatically-set short value and valid=false, tooShort=true for the same string typed. minlength/maxlength are gated on the spec's dirty value flag. A test that sets .value passes while the real form rejects the input — the same class of bug as fe-01's .click() finding.

  7. "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.

  8. "aria-label is 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.

  9. "display: none and aria-hidden and hidden are 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.

  10. "Portalling to document.body is 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-describedby wiring, cross-field rules."
  • "A subtle one: minlength only applies to user-edited values because of the dirty value flag. So a test that sets .value directly never sees tooShort and 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-expanded on 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.