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.
<button> is not a <div>
Section titled “<button> is not a <div>”A native <button>:
- Is in the tab order without
tabindex. - Activates on
EnterandSpace. - Is announced by screen readers as “button” with its accessible name (the text inside, or
aria-label, oraria-labelledby). - Submits the form it’s inside, unless it’s
type="button". - Triggers
:focus-visiblecorrectly. - Has
disabledsemantics that propagate through the accessibility tree. - Has
aria-pressedandaria-expandedif 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-visible is what you want
Section titled “:focus-visible is what you want”: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.
inert removes a subtree from interaction
Section titled “inert removes a subtree from interaction”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.
aria-live for dynamic content
Section titled “aria-live for dynamic content”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())}Reduced motion
Section titled “Reduced motion”@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).
High contrast mode
Section titled “High contrast mode”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.
The bigger point
Section titled “The bigger point”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.
Read more
Section titled “Read more”You’ve now read the long-form pitch. The next section explains how Kitsune actually works.