Crystallize logo

Storefront accessibility: the tree that screen readers and AI browsers read

Screen readers, voice control and AI browsers all use the page's accessibility tree: the roles, names and states of its elements. A storefront that gets the tree right works for shoppers with assistive technology and for agents that shop on a person's behalf. This page lists the patterns a storefront most often gets wrong, and the native HTML that fixes each.

Why this matters twice

An AI browser or browser agent doesn't see your CSS. It reads the accessibility tree, decides which control does what from its role and name, and acts on it. A cart drawer that is hidden but still focusable, a quantity shown as plain text between two "−" and "+" buttons, or an error that appears without being announced confuses an agent in the same way it confuses a screen reader user. Fixing these is the same work.

Prefer native HTML. It brings the right role, name, keyboard behaviour and state with it, and it works without JavaScript.

Drawers and dialogs

Cart drawers, filter panels and assistants are dialogs. Use <dialog> and open it with showModal():

<button type="button" onclick="document.getElementById('cart').showModal()">Basket (2)</button>

<dialog id="cart" aria-labelledby="cart-title" closedby="any">
  <h2 id="cart-title">Your basket</h2>
  <!-- lines -->
  <button type="button" autofocus onclick="this.closest('dialog').close()">Close</button>
</dialog>

showModal() makes the rest of the page inert, exposes the drawer as a modal dialog, moves focus into it, closes it on Escape and returns focus when it closes. Don't add role="dialog", a focus trap or an Escape handler on top. closedby="any" adds light dismiss (a click outside).

  • Never put `aria-hidden` on something that can still take focus. A closed drawer that is only moved off screen is still reachable with Tab. A closed <dialog> is not.
  • Don't lock scrolling with `overflow: hidden` on `<html>` or `<body>`. It shifts the layout and breaks sticky elements. Use overscroll-behavior: contain on the open dialog and its backdrop.

Visible focus

The focus indicator needs a contrast of at least 3:1 against the colours around it. Brand accent colours (light greens, yellows) often fail on white or off-white backgrounds. Use :focus-visible, and don't replace the outline on inputs with a thin border change.

Controls an agent can operate

  • Quantity is an input. Use a labelled <input type="number" name="quantity" min="1">, with the − and + buttons as helpers. An agent can then read and set the quantity in one step instead of clicking N times.
  • Name buttons after what they act on. "Remove Cordless drill 18V from basket" and "Increase quantity of Cordless drill 18V", not "×" and "+1". Visible text can stay short when the accessible name says the rest.
  • Variant choices are radio buttons in a <fieldset> with a <legend> ("Colour", "Version"), not clickable <div>s.
  • Facets are links or a real form. Filter links that are plain URLs work for crawlers, agents and shoppers without JavaScript.

Forms and errors

  • Announce errors. Put checkout and booking errors in a live region (role="alert" for errors, role="status" for progress), or move focus to the error summary.
  • Use autofill tokens on checkout fields: autocomplete="email", name, shipping street-address, postal-code, tel. Shoppers fill the form in one tap, and agents know what each field is.
  • Offer guest checkout. Don't make account creation a condition of buying.
  • Enter that submits must be IME-safe. Shoppers typing Chinese, Japanese or Korean confirm characters with Enter. Skip the submit while composing:
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
  if (e.key !== 'Enter' || e.shiftKey) return;
  if (e.nativeEvent.isComposing || e.keyCode === 229) return;
  e.preventDefault();
  send();
}

Chat and streaming content

An on-site assistant streams its answer. A role="log" region is right for a chat, but don't let it announce every token: update the live region when a message is complete, or debounce it. Show "thinking" as role="status" text, not as an aria-label on an element without a role.

Motion

Respect prefers-reduced-motion per animation. Drop movement (a drawer sliding in, a scale on hover) and keep transitions that don't move things, such as a fade. A global rule that sets every animation and transition to 0.01 ms also removes feedback that helps.

tip

Test with the tree, not only the screen

Open the browser's accessibility tree view (in Chrome DevTools, the Accessibility pane) and tab through the page with the screen off. If you can't tell what a control does from its role and name, neither can an agent.