Skip to content

ARIA, Focus, and Accessibility

The shortest path to an accessible app is to use native elements where they exist. The shortest path to an inaccessible one is to replace them with <div>s.

This page is the practical version of that argument.

A native <button>:

  • Is in the tab order without tabindex.
  • Activates on Enter and Space.
  • Is announced by screen readers as “button” with its accessible name (the text inside, or aria-label, or aria-labelledby).
  • Submits the form it’s inside, unless it’s type="button".
  • Triggers :focus-visible correctly.
  • Has disabled semantics that propagate through the accessibility tree.
  • Has aria-pressed and aria-expanded if you set them.

A <div onclick> has none of those. You can put them all back, individually, with tabindex="0", role="button", an onkeydown handler that intercepts Enter and Space, an aria-disabled shim that doesn’t actually disable the click, and so on. You will get most of them right. You will get one of them wrong.

<kit-button> puts a real <button> inside its shadow DOM. Click it, focus it, tab to it — it’s a button.

:focus matches whether you tabbed in, clicked in, or scripted it. :focus-visible matches only when the user is doing keyboard navigation.

:focus { outline: none; } /* AVOID — kills focus for keyboard users */
:focus-visible { outline: 2px solid; outline-offset: 2px; } /* better */

Kitsune components use :focus-visible. You should too.

When a modal opens, the rest of the page should be inert. Native <dialog>.showModal() does this for you. For non-dialog cases, the inert attribute does it manually:

<main inert>...</main>
<aside>This sidebar still works.</aside>

Inert elements are unfocusable and uninteractable. Screen readers skip them. No focus trap library needed.

The toast region in <kit-toast-region> uses aria-live="polite". When a toast is appended:

<div role="region" aria-label="Notifications" aria-live="polite">
<div role="status">Saved</div>
</div>

Screen readers announce “Saved” without focus moving. polite waits for the user to finish what they’re doing; assertive interrupts. role="status" is for low-priority updates; role="alert" is for errors and is implicitly aria-live="assertive".

aria-describedby and aria-labelledby for relationships

Section titled “aria-describedby and aria-labelledby for relationships”
<label for="email">Email</label>
<input id="email" aria-describedby="email-help email-error" />
<div id="email-help">We'll never share it.</div>
<div id="email-error" role="alert">Email is required.</div>

Screen readers announce the label, then the input, then the description, then the error. <kit-field> wires this up automatically when you give it label, description, and/or error properties.

Focus management is intentional, not automatic

Section titled “Focus management is intentional, not automatic”

Kitsune’s rule: focus where the user’s attention should go.

  • A dialog opens — focus moves to it (native <dialog> does this).
  • A dialog closes — focus returns to the element that opened it (native <dialog> does this).
  • A toast appears — focus does not move (the user is doing something else).
  • A list filter narrows results — focus stays in the filter input.
  • A new page loads via SPA navigation — focus moves to the page heading.

Custom elements that move focus should do so on connectedCallback after the element is in the document, after one frame so the screen reader has time to register the element first.

connectedCallback() {
super.connectedCallback()
requestAnimationFrame(() => this.focus())
}
@media (prefers-reduced-motion: reduce) {
*, ::before, ::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

Or, more carefully, suppress specific animations and keep semantically meaningful ones (e.g., progress indicators).

Use system colors as defaults so high-contrast mode works:

:host {
background: var(--kit-color-surface, Canvas);
color: var(--kit-color-text, CanvasText);
border: 1px solid var(--kit-color-border, CanvasText);
}

Canvas, CanvasText, Highlight, and similar tokens adapt automatically when the user sets a forced-colors theme.

Kitsune doesn’t have an accessibility layer because it doesn’t need one. Native <button>, native <dialog>, native <input>, native ARIA-live regions, native focus management, native form association — every Kitsune element is a thin wrapper around the platform’s accessibility model.

The platform is your accessibility framework. Use it directly and you will be more accessible by default than any framework that hides it from you.

You’ve now read the long-form pitch. The next section explains how Kitsune actually works.

Next: Web Native (Concepts) →