Accessibility Basics Every Developer Should Know in 2026
Accessibility is not a feature. It is a quality attribute of every feature you build. A button that cannot be activated with a keyboard is a broken button. A form that does not announce errors to screen readers is a broken form. We do not treat accessibility as a checklist we run at the end of a project — we build it into every component from the start.
This is not a comprehensive guide to WCAG 2.2. It is the practical subset we apply on every project at Threshline — the patterns that cover 90% of real-world accessibility issues with minimal overhead.
Semantic HTML Is the Foundation
The single most impactful thing you can do for accessibility is use the correct HTML elements. Browsers and assistive technologies have spent decades building support for native HTML. When you use a <button> instead of a <div onclick>, you get keyboard activation, focus management, screen reader announcements, and proper role mapping — for free.
Here is a list of elements we see misused constantly:
<!-- Bad: div as a button -->
<div class="btn" onclick="handleClick()">Save</div>
<!-- Good: actual button -->
<button type="button" onclick="handleClick()">Save</button>
<!-- Bad: div as a link -->
<div class="link" onclick="navigate('/about')">About</div>
<!-- Good: actual anchor -->
<a href="/about">About</a>
<!-- Bad: div as a list -->
<div class="nav-items">
<div class="nav-item">Home</div>
<div class="nav-item">About</div>
</div>
<!-- Good: nav with list -->
<nav aria-label="Main navigation">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/about">About</a></li>
</ul>
</nav>
The <div> versions require you to manually add role, tabindex, onkeydown handlers for Enter and Space, and ARIA attributes. The semantic versions do all of this natively. Using semantic HTML is faster to write and more reliable.
Other elements we use deliberately:
<main>for the primary content area (one per page)<header>and<footer>for page-level landmarks<section>with anaria-labelledbyoraria-labelfor distinct content regions<h1>through<h6>in proper hierarchy (never skip levels)<time datetime="2026-02-13">for dates<figure>and<figcaption>for images with descriptions
<main>
<h1>Dashboard</h1>
<section aria-labelledby="projects-heading">
<h2 id="projects-heading">Active Projects</h2>
<!-- project cards -->
</section>
<section aria-labelledby="activity-heading">
<h2 id="activity-heading">Recent Activity</h2>
<!-- activity feed -->
</section>
</main>
Screen reader users navigate by headings and landmarks. If your page is a flat sea of <div> elements, they have no way to jump to the section they need. Semantic structure gives them a table of contents for your page.
ARIA: When HTML Is Not Enough
ARIA (Accessible Rich Internet Applications) attributes fill the gaps where native HTML falls short — typically for custom interactive widgets that have no HTML equivalent.
The first rule of ARIA: do not use ARIA if a native HTML element does the same thing. Adding role="button" to a <div> is strictly worse than using a <button>. ARIA should be a last resort, not a default.
That said, there are legitimate cases where ARIA is necessary. Here are the patterns we use most:
Live regions for dynamic content:
When content updates without a page reload — toast notifications, form errors, chat messages — screen readers need to know about it. aria-live announces changes as they happen.
<!-- Polite: announced after the current speech finishes -->
<div aria-live="polite" aria-atomic="true">
{#if saveStatus === 'saved'}
<p>Changes saved successfully.</p>
{/if}
</div>
<!-- Assertive: interrupts current speech (use sparingly) -->
<div aria-live="assertive" role="alert">
{#if error}
<p>{error}</p>
{/if}
</div>
We use aria-live="polite" for success messages and status updates, and role="alert" (which implies aria-live="assertive") for errors that need immediate attention. The toast notification system we described in our post on micro-interactions uses aria-live="polite" so notifications are announced without interrupting whatever the user is doing.
Descriptions and labels:
Every interactive element needs an accessible name. For most elements, visible text provides this automatically. For icon buttons and other visual-only controls, you need explicit labels:
<!-- Icon button with no visible text -->
<button type="button" aria-label="Close dialog">
<svg aria-hidden="true"><!-- X icon --></svg>
</button>
<!-- Input with description -->
<label for="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-hint"
/>
<p id="password-hint" class="text-sm text-gray-500">
Must be at least 8 characters with one number.
</p>
Note aria-hidden="true" on the SVG icon. Decorative images and icons should be hidden from screen readers. The button’s label comes from aria-label, not the icon.
Expanded/collapsed states:
Toggleable sections (accordions, dropdowns, disclosures) need to communicate their state:
<script lang="ts">
let expanded = $state(false);
</script>
<button
type="button"
aria-expanded={expanded}
aria-controls="faq-answer-1"
onclick={() => (expanded = !expanded)}
class="flex w-full items-center justify-between py-3 text-left font-medium"
>
<span>How do I cancel my subscription?</span>
<svg
class="h-5 w-5 transition-transform duration-200
{expanded ? 'rotate-180' : ''}"
aria-hidden="true"
viewBox="0 0 20 20"
fill="currentColor"
>
<path fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd" />
</svg>
</button>
{#if expanded}
<div id="faq-answer-1" role="region">
<p>Go to Settings, then Billing, then click "Cancel subscription."</p>
</div>
{/if}
aria-expanded tells screen readers whether the content is visible. aria-controls associates the button with the content it toggles.

Keyboard Navigation
Every interactive element must be operable with a keyboard. This is not just for screen reader users — it covers power users who prefer keyboard shortcuts, users with motor impairments who use switch devices, and anyone whose mouse battery just died.
Focus management for modals:
Modals are the most common keyboard accessibility failure we encounter. When a modal opens, focus must move into it. When it closes, focus must return to the element that opened it. While open, Tab must cycle within the modal (focus trap).
// Focus trap utility
export function trapFocus(container: HTMLElement) {
const focusable = container.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
container.addEventListener('keydown', handleKeydown);
first?.focus();
return {
destroy() {
container.removeEventListener('keydown', handleKeydown);
},
};
}
<!-- Modal component with focus management -->
<script lang="ts">
import { trapFocus } from '$lib/utils/a11y';
let { open, onclose }: { open: boolean; onclose: () => void } = $props();
let previouslyFocused: HTMLElement | null = null;
$effect(() => {
if (open) {
previouslyFocused = document.activeElement as HTMLElement;
} else {
previouslyFocused?.focus();
}
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') onclose();
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/50"
onclick={onclose}
role="presentation"
></div>
<!-- Modal -->
<div
use:trapFocus
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onkeydown={handleKeydown}
class="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2
-translate-y-1/2 rounded-xl bg-white p-6 shadow-xl"
>
<h2 id="modal-title">Confirm Deletion</h2>
<p>Are you sure you want to delete this project?</p>
<div class="mt-4 flex justify-end gap-3">
<button type="button" onclick={onclose}>Cancel</button>
<button type="button" class="bg-red-600 text-white">Delete</button>
</div>
</div>
{/if}
This handles focus trapping, Escape to close, restoring focus on close, aria-modal to indicate the modal context, and a backdrop click to dismiss. We use this pattern on every product we build. The modal on Vincelio for confirming campaign actions uses this exact structure.
Skip links:
Users who navigate with a keyboard should be able to skip past the navigation to the main content. This is a simple pattern that many sites miss:
<body>
<a href="#main-content"
class="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4
focus:z-50 focus:rounded-lg focus:bg-white focus:px-4 focus:py-2
focus:shadow-lg">
Skip to main content
</a>
<nav><!-- navigation --></nav>
<main id="main-content" tabindex="-1">
<!-- page content -->
</main>
</body>
The sr-only class hides the link visually but keeps it in the tab order. When a keyboard user presses Tab on page load, the skip link appears. Pressing Enter jumps focus to <main>.
Color Contrast and Visual Design
WCAG 2.2 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18px+ or 14px+ bold). These are minimums, not targets — we aim higher.
Common violations:
- Light gray text on white backgrounds.
#9CA3AF(Tailwind’s gray-400) on white has a contrast ratio of 2.9:1. Fail. Use#6B7280(gray-500, ratio 4.6:1) or darker. - Placeholder text that is too light. Default browser placeholder color often fails contrast requirements. Style it explicitly.
- Status indicators that rely only on color. “Green means success, red means error” is invisible to color-blind users. Always include text or an icon alongside color.
<!-- Bad: color only -->
<span class="text-green-500">Active</span>
<span class="text-red-500">Failed</span>
<!-- Good: color + text + icon -->
<span class="inline-flex items-center gap-1 text-green-700">
<svg aria-hidden="true" class="h-4 w-4"><!-- checkmark --></svg>
Active
</span>
<span class="inline-flex items-center gap-1 text-red-700">
<svg aria-hidden="true" class="h-4 w-4"><!-- X icon --></svg>
Failed
</span>
We check contrast ratios during development using the Chrome DevTools color picker, which shows the ratio inline when you inspect a text element. This is faster than running an audit after the fact.

Accessible Forms
Forms are where accessibility matters most — they are how users provide data and complete tasks. A form that is difficult to use with a screen reader or keyboard is a form that loses users.
The rules:
- Every input has a visible label. Placeholder text is not a label — it disappears when the user types.
- Labels are associated with inputs via
for/idor by wrapping the input in the<label>element. - Required fields are indicated programmatically, not just visually.
- Error messages are associated with the input via
aria-describedbyand announced viaaria-liveorrole="alert".
<form novalidate onsubmit={handleSubmit}>
<div class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">
Full Name <span class="text-red-600" aria-hidden="true">*</span>
</label>
<input
id="name"
type="text"
required
aria-required="true"
aria-invalid={errors.name ? 'true' : undefined}
aria-describedby={errors.name ? 'name-error' : undefined}
bind:value={formData.name}
class="mt-1 w-full rounded-lg border px-3 py-2
{errors.name ? 'border-red-500' : 'border-gray-300'}"
/>
{#if errors.name}
<p id="name-error" class="mt-1 text-sm text-red-600" role="alert">
{errors.name}
</p>
{/if}
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">
Email Address <span class="text-red-600" aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
required
aria-required="true"
aria-invalid={errors.email ? 'true' : undefined}
aria-describedby="email-hint {errors.email ? 'email-error' : ''}"
bind:value={formData.email}
class="mt-1 w-full rounded-lg border px-3 py-2
{errors.email ? 'border-red-500' : 'border-gray-300'}"
/>
<p id="email-hint" class="mt-1 text-sm text-gray-500">
We will use this to send booking confirmations.
</p>
{#if errors.email}
<p id="email-error" class="mt-1 text-sm text-red-600" role="alert">
{errors.email}
</p>
{/if}
</div>
</div>
<button type="submit" class="mt-6 rounded-lg bg-blue-600 px-6 py-3 text-white">
Create Account
</button>
</form>
Note the visual asterisk has aria-hidden="true" because screen readers will announce the aria-required="true" attribute instead. The aria-invalid attribute changes dynamically when errors are present, and aria-describedby links the error message to the input so screen readers announce it in context.
Testing Tools
You cannot verify accessibility by looking at the page. You need to test with the tools your users rely on.
Automated testing (catches about 30% of issues):
- axe DevTools (browser extension): Run it on any page to find contrast violations, missing labels, invalid ARIA, and structural issues. Free and fast.
- Lighthouse Accessibility audit: Built into Chrome DevTools. Scores your page and lists specific violations. Good for a quick overview.
- eslint-plugin-jsx-a11y (for React projects) or manual review for Svelte. Catches issues at build time.
# Run axe-core in CI with Playwright
npm install -D @axe-core/playwright
# In your Playwright test
import AxeBuilder from '@axe-core/playwright';
test('home page has no accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
Adding axe to your Playwright or Cypress tests catches regressions automatically. We run this on every PR.
Manual testing (catches the other 70%):
- Keyboard-only navigation: Unplug your mouse and try to use your app. Can you reach every interactive element? Can you see where focus is? Can you operate modals, dropdowns, and forms?
- Screen reader testing: On macOS, VoiceOver is built in (Cmd+F5 to toggle). On Windows, use NVDA (free). Navigate your app and listen. Is the content announced in a logical order? Are buttons and links described clearly? Are state changes (expanded, selected, error) communicated?
- Zoom testing: Zoom the browser to 200% and 400%. Does the layout still work? Can you read all text? Do any interactive elements overlap or disappear?
We test with VoiceOver on every project before shipping. It takes about 30 minutes per page and consistently reveals issues that automated tools miss — wrong reading order, confusing link text (“click here” repeated 12 times), or custom widgets that are completely invisible to screen readers.

Building It Into the Process
Accessibility is cheapest when it is built in from the start. Retrofitting an inaccessible app is expensive and frustrating — like adding tests to untested code, but worse because the fixes often require structural changes.
Our process at Threshline:
- Use semantic HTML by default. This handles the majority of accessibility for free.
- Check contrast during design. Before writing CSS, verify that color choices meet contrast ratios.
- Test with keyboard during development. Every time you build a new interactive component, Tab through it.
- Run axe in CI. Automated tests catch regressions that slip through code review.
- Do a VoiceOver pass before launch. Manual screen reader testing is part of our launch readiness checklist.
This process adds about 10-15% overhead to development time. Fixing accessibility issues after launch adds 3-5x more. The math is clear.
It Is Not About Compliance
We could make the business case for accessibility: 15% of the global population has a disability, accessible sites rank better in search, lawsuits are increasing. All true, all beside the point.
The real reason is simpler: we build products for people to use. All people. A product that only works for sighted mouse users with perfect vision is a product that excludes a significant portion of its potential users. We can do better than that, and the techniques in this post show that doing better is not particularly hard.
If you are building a product and want to make sure it works for everyone — or if you have an existing product that needs accessibility improvements — reach out at hello@threshline.com.