Karl Koch | On the semantic web<br>Skip to main content
The easiest way to make an interface harder to maintain is to rebuild the browser badly.
A div can look like a button. It can be given a click handler, a pressed style, a hover state, a focus ring, an ARIA role, a keydown listener for Enter, another one for Space, a disabled class that hopefully also blocks interaction, and enough attributes to convince assistive tech that it is interactive.
Or it can be a button.
That sounds obvious until you look inside a lot of modern component libraries. The visual layer is treated as the source of truth, then the behaviour is patched back on afterwards. It works in the demo. It usually works with a mouse. Then someone tries to tab through it, submit it from a form, use it with VoiceOver, disable it properly, or nest it inside a more complex flow, and the component starts leaking implementation detail everywhere.
Semantic HTML is not an accessibility chore. It is interface infrastructure.
The fake button
The custom version usually starts like this:
div className="button" onClick={onSave}><br>Save changes<br>div><br>It looks fine once the CSS lands. But it has no role. It is not focusable. Space does nothing. Enter does nothing. It cannot be disabled. It does not submit a form. It does not announce itself as a button. The element has the visual affordance of an action without the platform behaviour of one.
So the patching begins:
div<br>role="button"<br>tabIndex={0}<br>aria-disabled={isDisabled}<br>className="button"<br>onClick={isDisabled ? undefined : onSave}<br>onKeyDown={(event) => {<br>if (event.key === "Enter" || event.key === " ") {<br>event.preventDefault();<br>onSave();<br>}}<br>Save changes<br>div><br>This is the bit that should make you suspicious. We have not designed a better button. We have started paying rent on a worse one.
The native version
The boring version is the better version:
button type="button" className="button" disabled={isDisabled} onClick={onSave}><br>Save changes<br>button><br>Now the browser does the work:
It enters the tab order.
It responds to Enter and Space.
It exposes its role and disabled state.
It blocks interaction when disabled.
It participates in forms predictably.
It inherits platform conventions users already understand.
The styling can still be completely custom. Semantic HTML does not mean accepting the browser’s default visual design. It means accepting the browser’s interaction contract before you paint over it.
That is where the design-engineering part sits. The question is not “can I make this look custom?” It is “which native behaviour should survive the styling?”
Button truth table
Here is the invisible contract made visible. The three controls look alike, but they do not behave alike. Try clicking them, toggling disabled, tabbing into them, and pressing Enter or Space.
Disabled state: off<br>Toggle disabled<br>Bare divclickable-looking<br>Patched divrole + tab + keys<br>Native buttonbrowser contract<br>BehaviourDivPatchedButtonTab focusNoYesYesEnter activatesNoManualYesSpace activatesNoManualYesDisabled blocks clickManualManualYesExposes roleNoManualYes<br>Event log<br>Try clicking, tabbing, Enter, and Space.
Try the three controls with mouse and keyboard. They look alike, but only the native button carries the full contract without extra code.
The point is not to shame custom UI. The point is to make the invisible contract visible. Once you can see the missing behaviours, the native element stops feeling like a constraint.
Disclosure before accordion
The same thing happens with accordions. The instinct is to reach for state:
const [isOpen, setIsOpen] = useState(false);
return (<br>section><br>button<br>type="button"<br>aria-expanded={isOpen}<br>onClick={() => setIsOpen((open) => !open)}<br>Delivery details<br>button><br>{isOpen ? div>Ships in 2-3 working days.div> : null}<br>section><br>);<br>This can be right. If the component needs custom keyboard behaviour, animated height orchestration, controlled state, or multi-panel coordination, owning the state is reasonable.
But a lot of disclosure UI does not need that. It needs a summary and some content:
details><br>summary>Delivery detailssummary><br>p>Ships in 2-3 working days.p><br>details><br>That gives you a toggleable disclosure with built-in semantics. The browser exposes the expanded state. The summary is keyboard reachable. The content relationship is understood. You can still style the marker, spacing, typography, border, and open state.
The important bit is that “native” does not have to look like a browser default:
Custom state<br>Delivery details›<br>You own the open state, the button, the content relationship, and any multi-panel rules.
Native disclosure<br>Delivery details›Ships in 2-3 working days. Returns are free for 30 days.<br>You style the same surface, but the element already knows it is a disclosure.
Both disclosures are styled the same. The native version starts from details/summary, so expanded state and keyboard behaviour are built into the element.
The native version is not less designed....