· 7 min read
Building Accessible Components from Scratch
Accessibility isn't a feature you bolt on at the end. Here's how to build ARIA-compliant UI components without relying on a component library.
By Tanner Goins

When I rebuilt my design system from scratch, I made a deliberate choice not to use a component library. I wanted full control over the HTML, the styling, and the behavior. That meant I also owned the accessibility. No library was handling it for me.
This turned out to be one of the most educational decisions I’ve made as a frontend developer. Building accessible components from scratch teaches you things that using a pre-built library never will: how screen readers actually interpret your HTML, why keyboard navigation matters, and where the specification ends and judgment begins.
Here’s what I’ve learned from building components that work for everyone.
Why Accessibility Matters (Beyond Compliance)
Let me skip the legal argument. Yes, accessibility is increasingly a legal requirement. But the practical reasons are more compelling:
Accessible components are better components. The constraints that accessibility imposes (semantic HTML, keyboard support, clear focus states) result in components that are more robust, more testable, and more usable for everyone, not just users with disabilities.
Keyboard users are everywhere. Power users, developers, people with temporary injuries, people using a laptop without a mouse on a train. Keyboard navigation isn’t a niche need. It’s how a significant portion of your audience interacts with your site.
SEO benefits. Semantic HTML, the foundation of accessibility, is exactly what search engines want. An accessible site tends to be a well-ranked site.
The Foundation: Semantic HTML
Most accessibility problems aren’t solved with ARIA. They’re solved by using the right HTML element in the first place.
Use Native Elements
Buttons should be <button>, not <div onclick>.
<!-- Bad: div pretending to be a button -->
<div class="btn" onclick={handleClick}>Click me</div>
<!-- Good: actual button -->
<button class="btn" onclick={handleClick}>Click me</button> The <button> element gives you for free:
- Keyboard activation (Enter and Space)
- Focus management
- Screen reader announcement as “button”
- Disabled state support
A <div> gives you none of that. You’d need to add role="button", tabindex="0", keyboard event handlers, and ARIA states manually. Why rebuild what HTML already provides?
The Right Element for the Job
| Need | Use | Not |
|---|---|---|
| Navigation | <nav> | <div class="nav"> |
| Main content | <main> | <div class="main"> |
| Heading hierarchy | <h1> through <h6> | <div class="heading"> |
| List of items | <ul> or <ol> | <div> with <div> children |
| Form input label | <label for="..."> | <span> above an input |
| Link to another page | <a href="..."> | <span onclick> |
| Toggle state | <button> with aria-pressed | <div> with click handler |
The rule is simple: if an HTML element exists for your use case, use it. Reach for ARIA only when HTML can’t express what you need.
Keyboard Navigation
Every interactive element must be reachable and operable with a keyboard. This means:
Tab Order
Interactive elements should follow a logical tab order. In most cases, the DOM order is the tab order. If your visual layout matches your DOM order, tab navigation works naturally.
When it doesn’t match (CSS Grid or Flexbox reordering), you need to be careful. A user pressing Tab expects to move through the page in visual order. If tabbing jumps them to an unexpected location, the experience is disorienting.
Avoid tabindex values greater than 0. tabindex="0" adds an element to the natural tab order. tabindex="-1" makes an element programmatically focusable but not in the tab order. Any positive value creates a custom tab order that’s almost always confusing.
Focus Visibility
When a user tabs to an element, they need to see where they are. The default browser focus outline works, but many designs suppress it, creating a visually polished but inaccessible experience.
/* Don't do this */
*:focus {
outline: none;
}
/* Do this instead */
:focus-visible {
outline: 2px solid var(--color-teal-500);
outline-offset: 2px;
border-radius: 4px;
} :focus-visible is the modern approach: it shows focus styles for keyboard users but hides them for mouse users, giving you the best of both worlds.
Keyboard Patterns by Component
Different components have different keyboard expectations. The WAI-ARIA Authoring Practices define these patterns:
Dropdown menu:
- Enter/Space: open menu
- Arrow keys: navigate items
- Escape: close menu
- Home/End: jump to first/last item
Modal dialog:
- Escape: close
- Tab: cycle through focusable elements within the modal (focus trap)
- Focus returns to the trigger when closed
Tab panel:
- Arrow keys: switch between tabs
- Tab: move into the panel content
- Home/End: first/last tab
You don’t need to memorize these. Reference the WAI-ARIA Authoring Practices as you build. The point is that these patterns exist and users expect them.
Building a Real Component: Accessible Modal
Let me walk through building an accessible modal in Svelte 5 to show how these principles come together.
The Requirements
An accessible modal must:
- Trap focus inside the modal when open
- Return focus to the trigger when closed
- Close on Escape key press
- Have
role="dialog"andaria-modal="true" - Have a label (
aria-labelledbypointing to the title) - Prevent background content from being interactive
The Implementation
<script lang="ts">
import { untrack } from 'svelte';
import type { Snippet } from 'svelte';
let {
open = $bindable(false),
title,
children
}: {
open: boolean;
title: string;
children: Snippet;
} = $props();
let dialogEl: HTMLDialogElement;
let triggerEl: HTMLElement | null = $state(null);
$effect(() => {
if (open) {
triggerEl = untrack(() => document.activeElement as HTMLElement);
dialogEl?.showModal();
} else {
dialogEl?.close();
triggerEl?.focus(); // Return focus to trigger
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
open = false;
}
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === dialogEl) {
open = false;
}
}
</script>
<dialog
bind:this={dialogEl}
aria-labelledby="modal-title"
onkeydown={handleKeydown}
onclick={handleBackdropClick}
class="rounded-2xl shadow-xl p-0 backdrop:bg-black/50"
>
<div class="p-6 max-w-lg">
<header class="flex items-center justify-between mb-4">
<h2 id="modal-title" class="text-xl font-bold">{title}</h2>
<button
onclick={() => open = false}
aria-label="Close dialog"
class="p-1 rounded hover:bg-slate-100"
>
✕
</button>
</header>
<div>
{@render children()}
</div>
</div>
</dialog> What the <dialog> Element Gives Us
Using the native <dialog> element with showModal() handles several accessibility requirements automatically:
- Focus trapping: the browser traps focus inside the modal
- Escape to close: native behavior (we handle it explicitly for the binding)
- Background inert: content behind the modal becomes non-interactive
role="dialog": implicit from the element
We still need to add aria-labelledby, manage focus return, and handle the backdrop click ourselves. But the native element eliminates the hardest parts.
This is the theme: start with the right HTML element, and accessibility gets dramatically easier.
Common Components and Their ARIA Patterns
Toggle / Switch
<button
role="switch"
aria-checked={enabled}
onclick={() => enabled = !enabled}
class="..."
>
<span class="sr-only">{label}</span>
<!-- Visual toggle indicator -->
</button> Key points:
role="switch"tells screen readers this is a togglearia-checkedreflects the current state- The
sr-onlyspan provides a text label for screen readers - Use a
<button>, not a<div>
Accordion
<div>
<h3>
<button
aria-expanded={isOpen}
aria-controls="panel-{id}"
onclick={() => isOpen = !isOpen}
>
{title}
</button>
</h3>
{#if isOpen}
<div id="panel-{id}" role="region" aria-labelledby="heading-{id}">
{@render children()}
</div>
{/if}
</div> Key points:
- The trigger is a
<button>inside a heading aria-expandedcommunicates statearia-controlslinks the button to the panel- The panel has
role="region"with a label
Tabs
<div role="tablist" aria-label={label}>
{#each tabs as tab, i}
<button
role="tab"
id="tab-{i}"
aria-selected={activeTab === i}
aria-controls="panel-{i}"
tabindex={activeTab === i ? 0 : -1}
onclick={() => activeTab = i}
>
{tab.label}
</button>
{/each}
</div>
<div
role="tabpanel"
id="panel-{activeTab}"
aria-labelledby="tab-{activeTab}"
>
{@render tabs[activeTab].content()}
</div> Key points:
- Only the active tab has
tabindex="0"; arrow keys navigate between tabs aria-selectedmarks the active tab- Each panel is labeled by its corresponding tab
Testing Accessibility
Manual Testing
The fastest way to find accessibility issues:
- Tab through the page. Can you reach everything interactive? Can you see where focus is? Can you operate every control?
- Use a screen reader. Turn on VoiceOver (Mac), NVDA (Windows), or Orca (Linux) and navigate your page. Does it make sense aurally?
- Zoom to 200%. Does the layout break? Can you still read and interact with everything?
- Disable CSS. Does the page still have a logical structure? This reveals whether your HTML is semantic.
Automated Testing
Automated tools catch about a third of accessibility issues, but they catch the easy ones fast:
- Axe DevTools (browser extension) scans the current page and reports violations
- Lighthouse (built into Chrome DevTools) includes an accessibility audit
- eslint-plugin-jsx-a11y or eslint-plugin-svelte catches issues in your templates at build time
These tools won’t find everything. They can’t tell if your custom component makes sense to a screen reader user. But they’ll catch missing alt text, broken ARIA relationships, and color contrast failures.
The Practical Takeaway
You don’t need to be an accessibility expert to build accessible components. You need to follow three principles:
- Use semantic HTML first. The right element is almost always better than the right ARIA attribute.
- Make it keyboard-operable. If you can’t use it without a mouse, it’s not accessible.
- Test with real tools. Tab through your page. Turn on a screen reader. Run an automated scan.
Accessibility isn’t a separate skill from frontend development. It is frontend development, done correctly. The same attention to detail that makes a component well-designed makes it accessible, because accessible components are, by definition, components that work for everyone.
Start with your next component. Use the right HTML element. Add keyboard support. Test it. That’s the whole process.