Skip to content
➜cat blog/accessible-interfaces-by-default.md

Building Accessible Interfaces Without Thinking About It

The semantic HTML patterns, ARIA conventions, and focus management habits that make accessibility automatic.

9 min

Accessibility isn't a feature you bolt on. It's a set of habits that, once internalized, cost almost nothing to maintain. After building interfaces for five years, including enterprise legal-tech tools where users navigate with keyboards all day, these are the patterns that made accessibility feel automatic.

Start with Semantic HTML

Ninety percent of accessibility is using the right HTML element. Not <div> with an onClick handler. The actual element.

<!-- This is a button. Use a button. -->
<button type="button" @click="toggle">Toggle menu</button>

<!-- This navigates. Use a link. -->
<a href="/blog">Read the blog</a>

<!-- This is a list of items. Use a list. -->
<ul>
  <li v-for="item in items" :key="item.id">{{ item.name }}</li>
</ul>

<button> gives you keyboard interaction (Enter and Space), focus management, and screen reader announcements for free. A <div> with a click handler gives you none of that. You'd need role="button", tabindex="0", and @keydown handlers to replicate what <button> does natively.

The same principle applies to layout:

<header role="banner">...</header>
<nav aria-label="Main navigation">...</nav>
<main id="main">...</main>
<footer aria-label="Site footer">...</footer>

Screen readers use these landmarks to let users jump between sections. Without them, navigating a page means arrowing through every single element.

Headings Are Navigation

Screen reader users navigate by headings more than anything else. The heading hierarchy needs to make sense as an outline:

<h1>Blog</h1>                          <!-- one per page -->
  <h2>Building Type-Safe APIs</h2>     <!-- section -->
    <h3>The Problem</h3>               <!-- subsection -->
    <h3>The Solution</h3>              <!-- subsection -->
  <h2>Why I Moved from React to Vue</h2>

Never skip levels. An <h1> followed by an <h3> breaks the implied structure. Never use headings for styling. Use CSS classes if you want text to look bigger.

Focus Management in SPAs

In traditional multi-page sites, the browser manages focus automatically. When a page loads, focus starts at the top. In SPAs, route changes don't trigger a page load, so focus stays wherever it was, often on a navigation link the user just clicked.

The fix: move focus to the main content on route change.

// composables/useFocusOnNavigation.ts
const route = useRoute()
const mainContent = ref<HTMLElement | null>(null)

watch(() => route.path, () => {
  nextTick(() => {
    mainContent.value?.focus()
  })
})
<main ref="mainContent" id="main" tabindex="-1">
  <slot />
</main>

tabindex="-1" makes the element focusable programmatically without adding it to the tab order. The user doesn't see a focus ring, but screen readers announce the new content.

Every page on this site has a skip link:

<a
  href="#main"
  class="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-[60]"
>
  Skip to content
</a>

It's invisible until focused (via Tab), then appears as a styled link. Keyboard users can bypass the navigation and jump directly to the content. This matters especially on pages with complex navs.

ARIA: Less Is More

The first rule of ARIA is don't use ARIA if native HTML does the job. ARIA is a patch for cases where HTML semantics are insufficient.

The attributes I use regularly:

  • aria-label: when the visible text isn't descriptive enough. A hamburger icon button needs aria-label="Toggle menu".
  • aria-expanded: on buttons that control collapsible content. true when open, false when closed.
  • aria-hidden="true": on decorative elements (icons next to text labels). The text conveys meaning; the icon is visual redundancy.
  • aria-live="polite": on regions that update dynamically (toast notifications, form validation). Screen readers announce changes without interrupting the user.
  • role="dialog" + aria-modal="true": on modal overlays.
<button
  :aria-expanded="isOpen"
  aria-controls="dropdown-menu"
  @click="toggle"
>
  Options
</button>

<div
  v-if="isOpen"
  id="dropdown-menu"
  role="menu"
>
  <!-- menu items -->
</div>

Forms That Communicate

Every input needs a label. Not a placeholder. A <label>:

<div>
  <label for="email" class="label-mono mb-1">Email</label>
  <input
    id="email"
    type="email"
    required
    aria-describedby="email-error"
  />
  <p v-if="error" id="email-error" role="alert" class="text-red-400 text-sm mt-1">
    {{ error }}
  </p>
</div>

aria-describedby connects the error message to the input. When the input is focused, the screen reader announces both the label and the error. role="alert" ensures the error is announced when it appears, not just when the input is re-focused.

Color and Contrast

The minimum contrast ratio for normal text is 4.5:1 (WCAG AA). For large text (18px+ bold or 24px+ regular), it's 3:1.

In a dark theme, this means body text on a dark background needs to be significantly lighter than you might expect. My token system uses gray-400 (#a1a1aa) on gray-950 (#09090b) for secondary text, and that's roughly 7.5:1 contrast, well above the minimum.

Never rely on color alone to convey information. Error states need more than a red border. Add an icon, a text message, or both.

The Reduced Motion Contract

Users who set prefers-reduced-motion: reduce have told you they don't want motion. Respect it:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

This is in the global CSS of this site. Every animation and transition is effectively disabled for users who request it. The site still works. Elements appear in their final state without the entrance animation.

Making It Automatic

The patterns above are habits, not decisions. I don't think "should this button be accessible?" because every button uses <button>. I don't think "should this error be announced?" because every error has role="alert". The overhead is near zero because the accessible path is the default path.

The investment is upfront: learn the right patterns once, use them every time. The alternative, building inaccessibly and fixing it later, always costs more.