On the semantic web

5 min read Original article ↗

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.

Many component libraries treat the visual layer as the source of truth, then patch behaviour back in. The demo works with a mouse, but keyboard navigation, form submission, VoiceOver, disabled states, and nested flows expose the missing platform behaviour.

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}>
  Save changes
</div>

It looks fine once the CSS lands, but it has no role and is not focusable. Neither Space nor Enter does anything; it cannot be disabled, submit a form, or 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
  role="button"
  tabIndex={0}
  aria-disabled={isDisabled}
  className="button"
  onClick={isDisabled ? undefined : onSave}
  onKeyDown={(event) => {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      onSave();
    }
  }}
>
  Save changes
</div>

This version recreates part of a native button while leaving your code responsible for its behaviour.

The native version

The native version is shorter:

<button type="button" className="button" disabled={isDisabled} onClick={onSave}>
  Save changes
</button>

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.

The design-engineering question 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.

The comparison makes the invisible interaction contract visible. Once you see the missing behaviours, the native element becomes useful infrastructure.

Disclosure before accordion

The same thing happens with accordions. The instinct is to reach for state:

const [isOpen, setIsOpen] = useState(false);

return (
  <section>
    <button
      type="button"
      aria-expanded={isOpen}
      onClick={() => setIsOpen((open) => !open)}
    >
      Delivery details
    </button>
    {isOpen ? <div>Ships in 2-3 working days.</div> : null}
  </section>
);

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>
  <summary>Delivery details</summary>
  <p>Ships in 2-3 working days.</p>
</details>

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:

The native version starts with the behaviour solved and leaves the visual design open.

Forms are full of this

Forms are where semantic shortcuts compound.

This:

<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />

is doing more than placing text near a rectangle.

The label increases the click target while the input exposes its purpose. The type changes the mobile keyboard and validation, the name makes submission work, autocomplete lets the browser help, and required gives the field a validity state.

The field can keep the same visual treatment either way. The difference is what the browser gets to understand:

You can recreate pieces of that by hand. You can also choose not to throw them away.

Semantics outlast styling

Visual systems change. Gradients and border radii cycle through fashions, and component shapes age.

The semantic layer lasts longer because it describes what the interface is, not how it currently looks.

A navigation landmark is still navigation after the redesign. A form control is still a form control after the brand refresh. A heading hierarchy is still the page’s outline when the typography changes. A button is still an action when the CSS moves from glassy to flat to something else.

That durability matters at scale. If every component encodes its own private version of “clickable thing”, every redesign has to preserve a pile of hidden behaviour. If the component starts from the platform primitive, the redesign mostly changes the surface.

Where custom components are worth it

Some interfaces need custom behaviour: a command menu, combobox with async search, reorderable list, rich text editor, or dense media scrubber. Native HTML will not carry all of that for you.

Exhaust the native contract first.

Ask:

  • Is there an element that already does this?
  • Does the custom version preserve keyboard behaviour?
  • Does it expose name, role, and state?
  • Does it work without a mouse?
  • Does it still make sense inside a form?
  • Does it need ARIA, or did I add ARIA because the markup was wrong?

ARIA is useful when you are building something the platform does not fully provide. It is a poor apology for ignoring the element that did.

When to steal this

Start with semantics whenever the interaction maps to an existing element: actions, links, form fields, navigation, headings, lists, disclosures, tables, and article structure.

Reach for custom primitives when the behaviour is genuinely new, not when the default styling feels inconvenient.

Put the visual design on top of the strongest available HTML contract. The result can feel crafted without making its behaviour fragile.