My Isekai Journey
13 min readUpdated 2026-02-16

Mastering Web Accessibility: Building for Everyone

A comprehensive guide to building accessible web applications that work beautifully for all users, regardless of ability.

Cover image for Mastering Web Accessibility: Building for Everyone

Why I care about accessibility#

Here's a stat that changed how I build: 15% of the world's population experiences some form of disability. That's over 1 billion people. And everyone experiences temporary or situational disabilities—a broken arm, bright sunlight on a screen, using a mobile device one-handed while carrying groceries.

Accessibility isn't a nice-to-have. It's about building products that work for everyone.

Let me show you how to make accessibility a natural part of your development process.

The POUR principles#

The Web Content Accessibility Guidelines (WCAG) are built on four principles:

  1. Perceivable - Information must be presentable to users in ways they can perceive
  2. Operable - User interface components must be operable
  3. Understandable - Information and UI must be understandable
  4. Robust - Content must be robust enough to work with current and future technologies

Let's explore each with practical examples.


Perceivable: See, hear, or feel your content#

Vision: Not everyone sees the same way#

Some users are blind, some have low vision, some are colorblind. Your interface needs to work for all of them.

1. Text alternatives for images

// ❌ Bad: No context
<img src="/chart.png" />
 
// ❌ Also bad: Vague description
<img src="/chart.png" alt="Chart" />
 
// ✅ Good: Descriptive alternative
<img
  src="/chart.png"
  alt="Bar chart showing website traffic increased 45% from January to June 2026"
/>
 
// ✅ Complex images: Use longdesc or aria-describedby
<img
  src="/complex-diagram.png"
  alt="System architecture diagram"
  aria-describedby="diagram-description"
/>
<div id="diagram-description" className="sr-only">
  The system consists of three layers: a React frontend
  communicating via REST API to a Node.js backend, which
  connects to a PostgreSQL database. Redis is used for caching...
</div>

Screen reader only class:

/* styles/utilities.css */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}

2. Color contrast

// lib/color-contrast.ts
export function getContrastRatio(color1: string, color2: string): number {
  const getLuminance = (rgb: number[]) => {
    const [r, g, b] = rgb.map((val) => {
      val = val / 255;
      return val <= 0.03928
        ? val / 12.92
        : Math.pow((val + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };
 
  // Parse colors and calculate luminance...
  const l1 = getLuminance(parseColor(color1));
  const l2 = getLuminance(parseColor(color2));
 
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
 
  return (lighter + 0.05) / (darker + 0.05);
}
 
// Usage in component
export function Button({ children }: { children: React.ReactNode }) {
  // Minimum 4.5:1 for normal text
  // Minimum 3:1 for large text (18pt+)
  return (
    <button
      className="bg-blue-600 text-white" // 4.52:1 ratio ✓
    >
      {children}
    </button>
  );
}

Don't rely on color alone:

// ❌ Bad: Color as only indicator
<div className="text-red-500">Error occurred</div>
<div className="text-green-500">Success</div>
 
// ✅ Good: Color + icon + text
<div className="flex items-center gap-2 text-red-500">
  <XCircleIcon className="h-5 w-5" aria-hidden="true" />
  <span>Error: File upload failed</span>
</div>
 
<div className="flex items-center gap-2 text-green-500">
  <CheckCircleIcon className="h-5 w-5" aria-hidden="true" />
  <span>Success: File uploaded</span>
</div>

Color contrast examples

3. Responsive text sizing

// ❌ Bad: Fixed pixel sizes that don't scale
<p style={{ fontSize: '14px' }}>Text</p>
 
// ✅ Good: Relative units that scale with user preferences
<p className="text-base"> {/* Uses rem units */}
  Text
</p>
 
// Tailwind config with accessible scale
// tailwind.config.ts
export default {
  theme: {
    fontSize: {
      xs: ['0.75rem', { lineHeight: '1rem' }],
      sm: ['0.875rem', { lineHeight: '1.25rem' }],
      base: ['1rem', { lineHeight: '1.5rem' }],
      lg: ['1.125rem', { lineHeight: '1.75rem' }],
      xl: ['1.25rem', { lineHeight: '1.75rem' }],
      '2xl': ['1.5rem', { lineHeight: '2rem' }],
    },
  },
};

4. Audio and video alternatives

export function VideoPlayer({ src }: { src: string }) {
  return (
    <div>
      <video controls>
        <source src={src} type="video/mp4" />
        {/* Captions for deaf/hard of hearing */}
        <track
          kind="captions"
          src="/captions-en.vtt"
          srcLang="en"
          label="English"
          default
        />
        {/* Descriptions for blind users */}
        <track
          kind="descriptions"
          src="/descriptions-en.vtt"
          srcLang="en"
          label="English descriptions"
        />
      </video>
      {/* Transcript for those who prefer reading */}
      <details className="mt-4">
        <summary>View transcript</summary>
        <div className="p-4 bg-gray-50">
          <h3>Video Transcript</h3>
          <p>In this video, we demonstrate...</p>
        </div>
      </details>
    </div>
  );
}

Operable: Everyone can use your interface#

Keyboard navigation must work perfectly#

Many users can't use a mouse. Your entire interface should be keyboard-accessible.

1. Focus management

// All interactive elements must be focusable
export function CustomButton({
  onClick,
  children,
}: {
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      onClick={onClick}
      className="focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
    >
      {children}
    </button>
  );
}
 
// If you must use divs, make them keyboard accessible
export function CustomDiv({
  onClick,
  children,
}: {
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={(e) => {
        if (e.key === "Enter" || e.key === " ") {
          e.preventDefault();
          onClick();
        }
      }}
      className="cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
    >
      {children}
    </div>
  );
}

2. Skip links for screen readers

// components/skip-link.tsx
export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white"
    >
      Skip to main content
    </a>
  );
}
 
// app/layout.tsx
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <body>
        <SkipLink />
        <header>...</header>
        <main id="main-content">{children}</main>
      </body>
    </html>
  );
}

3. Keyboard traps and focus restoration

"use client";
 
import { useEffect, useRef } from "react";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
 
export function Modal({
  isOpen,
  onClose,
  children,
}: {
  isOpen: boolean;
  onClose: () => void;
  children: React.ReactNode;
}) {
  const previousActiveElement = useRef<HTMLElement | null>(null);
 
  useEffect(() => {
    if (isOpen) {
      // Store the element that had focus before opening
      previousActiveElement.current = document.activeElement as HTMLElement;
    } else {
      // Restore focus when closing
      previousActiveElement.current?.focus();
    }
  }, [isOpen]);
 
  return (
    <Dialog open={isOpen} onClose={onClose}>
      {/* Focus is automatically trapped in dialog */}
      <div className="fixed inset-0 bg-black/30" aria-hidden="true" />
 
      <div className="fixed inset-0 flex items-center justify-center p-4">
        <DialogPanel className="bg-white rounded p-6">
          <DialogTitle className="text-lg font-bold">Dialog Title</DialogTitle>
 
          {children}
 
          <div className="mt-4 flex gap-2">
            <button onClick={onClose}>Cancel</button>
            <button onClick={onClose}>Confirm</button>
          </div>
        </DialogPanel>
      </div>
    </Dialog>
  );
}

4. Accessible dropdown navigation

"use client";
 
import { useState, useRef, useEffect } from "react";
 
export function DropdownMenu({
  label,
  items,
}: {
  label: string;
  items: Array<{ href: string; label: string }>;
}) {
  const [isOpen, setIsOpen] = useState(false);
  const menuRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
        setIsOpen(false);
      }
    }
 
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);
 
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch (e.key) {
      case "Escape":
        setIsOpen(false);
        break;
      case "ArrowDown":
        e.preventDefault();
        // Focus first item
        const firstItem = menuRef.current?.querySelector("a");
        firstItem?.focus();
        break;
    }
  };
 
  return (
    <div ref={menuRef} className="relative">
      <button
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        aria-expanded={isOpen}
        aria-haspopup="true"
        aria-controls="dropdown-menu"
      >
        {label}
      </button>
 
      {isOpen && (
        <div
          id="dropdown-menu"
          role="menu"
          className="absolute mt-2 bg-white shadow-lg rounded"
        >
          {items.map((item, index) => (
            <a
              key={item.href}
              href={item.href}
              role="menuitem"
              className="block px-4 py-2 hover:bg-gray-100"
              onKeyDown={(e) => {
                if (e.key === "ArrowDown") {
                  e.preventDefault();
                  const next = e.currentTarget
                    .nextElementSibling as HTMLElement;
                  next?.focus();
                } else if (e.key === "ArrowUp") {
                  e.preventDefault();
                  const prev = e.currentTarget
                    .previousElementSibling as HTMLElement;
                  prev?.focus();
                }
              }}
            >
              {item.label}
            </a>
          ))}
        </div>
      )}
    </div>
  );
}

5. No time limits (or make them adjustable)

"use client";
 
import { useState, useEffect } from "react";
 
export function SessionTimeout({ timeoutMs = 300000 }: { timeoutMs?: number }) {
  const [remaining, setRemaining] = useState(timeoutMs);
  const [isExtended, setIsExtended] = useState(false);
 
  useEffect(() => {
    const interval = setInterval(() => {
      setRemaining((prev) => Math.max(0, prev - 1000));
    }, 1000);
 
    return () => clearInterval(interval);
  }, []);
 
  // Show warning at 1 minute remaining
  if (remaining <= 60000 && remaining > 0) {
    return (
      <div
        role="alert"
        aria-live="assertive"
        className="fixed bottom-4 right-4 bg-yellow-100 border border-yellow-400 p-4 rounded"
      >
        <p className="font-bold">Session expiring soon</p>
        <p>Your session will expire in {Math.ceil(remaining / 1000)} seconds</p>
        <button
          onClick={() => {
            setRemaining(timeoutMs);
            setIsExtended(true);
          }}
          className="mt-2 px-4 py-2 bg-blue-600 text-white rounded"
        >
          Extend Session
        </button>
      </div>
    );
  }
 
  return null;
}

Understandable: Clear and predictable#

Forms that guide users#

"use client";
 
import { useState } from "react";
 
export function AccessibleForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});
 
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const email = formData.get("email") as string;
 
    const newErrors: Record<string, string> = {};
 
    if (!email) {
      newErrors.email = "Email is required";
    } else if (!/\S+@\S+\.\S+/.test(email)) {
      newErrors.email = "Please enter a valid email address";
    }
 
    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);
      // Focus first error
      const firstErrorField = document.querySelector(
        '[aria-invalid="true"]',
      ) as HTMLElement;
      firstErrorField?.focus();
    } else {
      // Submit form
      setErrors({});
    }
  };
 
  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className="mb-4">
        <label htmlFor="email" className="block mb-2 font-medium">
          Email address
          <span className="text-red-500" aria-label="required">
            *
          </span>
        </label>
 
        <input
          type="email"
          id="email"
          name="email"
          aria-required="true"
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : "email-hint"}
          className={`w-full px-3 py-2 border rounded ${
            errors.email ? "border-red-500" : "border-gray-300"
          }`}
        />
 
        {!errors.email && (
          <p id="email-hint" className="mt-1 text-sm text-gray-600">
            We'll never share your email
          </p>
        )}
 
        {errors.email && (
          <p
            id="email-error"
            className="mt-1 text-sm text-red-500"
            role="alert"
          >
            {errors.email}
          </p>
        )}
      </div>
 
      <button
        type="submit"
        className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Subscribe
      </button>
    </form>
  );
}

Error summary for multiple errors:

export function FormErrorSummary({
  errors,
}: {
  errors: Record<string, string>;
}) {
  if (Object.keys(errors).length === 0) return null;
 
  return (
    <div
      role="alert"
      aria-labelledby="error-summary-title"
      className="mb-6 p-4 bg-red-50 border border-red-200 rounded"
      tabIndex={-1}
      ref={(el) => el?.focus()}
    >
      <h2 id="error-summary-title" className="font-bold text-red-800 mb-2">
        There are {Object.keys(errors).length} errors in this form
      </h2>
      <ul className="list-disc list-inside">
        {Object.entries(errors).map(([field, message]) => (
          <li key={field}>
            <a href={`#${field}`} className="text-red-700 underline">
              {message}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Consistent navigation#

// components/site-nav.tsx
export function SiteNav() {
  return (
    <nav aria-label="Main navigation">
      <ul className="flex gap-4">
        <li>
          <a href="/" className="hover:underline">
            Home
          </a>
        </li>
        <li>
          <a href="/blog" className="hover:underline">
            Blog
          </a>
        </li>
        <li>
          <a href="/about" className="hover:underline">
            About
          </a>
        </li>
      </ul>
    </nav>
  );
}
 
// Multiple navs? Use labels
export function PageLayout() {
  return (
    <div>
      <nav aria-label="Main navigation">{/* Primary site navigation */}</nav>
 
      <aside>
        <nav aria-label="Table of contents">
          {/* Page-specific navigation */}
        </nav>
      </aside>
 
      <footer>
        <nav aria-label="Footer navigation">{/* Footer links */}</nav>
      </footer>
    </div>
  );
}

Robust: Works with assistive technologies#

Semantic HTML is your friend#

// ❌ Bad: Div soup
<div className="card">
  <div className="title">Article Title</div>
  <div className="meta">Published on Jan 1, 2026</div>
  <div className="content">Article content...</div>
  <div className="footer">
    <div onClick={handleLike}>Like</div>
    <div onClick={handleShare}>Share</div>
  </div>
</div>
 
// ✅ Good: Semantic HTML
<article>
  <header>
    <h2>Article Title</h2>
    <time dateTime="2026-01-01">Published on Jan 1, 2026</time>
  </header>
 
  <div className="content">
    <p>Article content...</p>
  </div>
 
  <footer>
    <button onClick={handleLike} aria-label="Like this article">
      <HeartIcon /> Like
    </button>
    <button onClick={handleShare} aria-label="Share this article">
      <ShareIcon /> Share
    </button>
  </footer>
</article>

ARIA when HTML isn't enough#

// Live regions for dynamic content
export function LiveSearchResults({
  query,
  results,
}: {
  query: string;
  results: Array<{ id: string; title: string }>;
}) {
  return (
    <div>
      <input
        type="search"
        value={query}
        aria-label="Search"
        aria-controls="search-results"
      />
 
      <div
        id="search-results"
        role="region"
        aria-live="polite"
        aria-atomic="true"
      >
        {results.length > 0 ? (
          <p className="sr-only">
            Found {results.length} result{results.length !== 1 ? "s" : ""}
          </p>
        ) : (
          <p className="sr-only">No results found</p>
        )}
 
        <ul role="list">
          {results.map((result) => (
            <li key={result.id}>
              <a href={`/posts/${result.id}`}>{result.title}</a>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Custom components with proper ARIA#

"use client";
 
import { useState } from "react";
 
export function Accordion({
  items,
}: {
  items: Array<{ id: string; title: string; content: string }>;
}) {
  const [expandedId, setExpandedId] = useState<string | null>(null);
 
  return (
    <div>
      {items.map((item) => {
        const isExpanded = expandedId === item.id;
 
        return (
          <div key={item.id} className="border-b">
            <h3>
              <button
                onClick={() => setExpandedId(isExpanded ? null : item.id)}
                aria-expanded={isExpanded}
                aria-controls={`panel-${item.id}`}
                className="w-full text-left px-4 py-3 font-medium flex justify-between items-center"
              >
                {item.title}
                <span aria-hidden="true">{isExpanded ? "−" : "+"}</span>
              </button>
            </h3>
 
            <div
              id={`panel-${item.id}`}
              role="region"
              aria-labelledby={`button-${item.id}`}
              hidden={!isExpanded}
              className="px-4 py-3"
            >
              {item.content}
            </div>
          </div>
        );
      })}
    </div>
  );
}

Accessibility tree visualization


Testing for accessibility#

Automated testing#

npm install --save-dev @axe-core/react jest-axe
// __tests__/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { MyComponent } from '@/components/my-component';
 
expect.extend(toHaveNoViolations);
 
describe('MyComponent accessibility', () => {
  it('should not have accessibility violations', async () => {
    const { container } = render(<MyComponent />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

Manual testing checklist#

// lib/a11y-checklist.ts
export const accessibilityChecklist = [
  {
    category: "Keyboard",
    checks: [
      "All interactive elements are keyboard accessible",
      "Focus order is logical",
      "Focus is visible",
      "No keyboard traps",
      "Skip link works",
    ],
  },
  {
    category: "Screen Reader",
    checks: [
      "All images have alt text",
      "Headings are hierarchical",
      "Landmarks are properly labeled",
      "Forms have labels",
      "Errors are announced",
    ],
  },
  {
    category: "Visual",
    checks: [
      "Color contrast meets WCAG AA (4.5:1)",
      "Text is resizable to 200%",
      "Content reflows at 320px width",
      "No information conveyed by color alone",
    ],
  },
  {
    category: "Content",
    checks: [
      "Language is set on html element",
      "Page title is descriptive",
      "Link text is meaningful",
      "Error messages are clear",
    ],
  },
];

Testing with real assistive technologies#

macOS VoiceOver:

# Enable VoiceOver: Cmd + F5
# Navigate: Control + Option + Arrow keys
# Interact: Control + Option + Space

Windows NVDA:

# Download from https://www.nvaccess.org/
# Navigate: Arrow keys
# Interact: Enter
# Read: Insert + Down arrow

Common patterns library#

// components/a11y/visually-hidden.tsx
export function VisuallyHidden({ children }: { children: React.ReactNode }) {
  return <span className="sr-only">{children}</span>;
}
 
// components/a11y/focus-trap.tsx
import { useEffect, useRef } from "react";
 
export function useFocusTrap<T extends HTMLElement>() {
  const ref = useRef<T>(null);
 
  useEffect(() => {
    const element = ref.current;
    if (!element) return;
 
    const focusableElements = element.querySelectorAll(
      "a[href], button:not([disabled]), textarea, input, select",
    );
 
    const firstElement = focusableElements[0] as HTMLElement;
    const lastElement = focusableElements[
      focusableElements.length - 1
    ] as HTMLElement;
 
    function handleTab(e: KeyboardEvent) {
      if (e.key !== "Tab") return;
 
      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    }
 
    element.addEventListener("keydown", handleTab);
    firstElement?.focus();
 
    return () => element.removeEventListener("keydown", handleTab);
  }, []);
 
  return ref;
}
 
// components/a11y/announce.tsx
export function LiveRegion({
  message,
  priority = "polite",
}: {
  message: string;
  priority?: "polite" | "assertive";
}) {
  return (
    <div
      role="status"
      aria-live={priority}
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

The business case#

Accessibility isn't just ethical—it's profitable:

  • Better SEO: Semantic HTML and alt text help search engines
  • Wider audience: 15% more potential users
  • Legal compliance: Avoid ADA lawsuits (average settlement: $245,000)
  • Better UX for everyone: Keyboard shortcuts, clear focus, good contrast help all users

Your implementation roadmap#

Week 1: Low-hanging fruit

  • Add alt text to all images
  • Ensure all buttons/links are keyboard accessible
  • Fix color contrast issues

Week 2: Forms and navigation

  • Add labels to all form inputs
  • Implement skip links
  • Test keyboard navigation flow

Week 3: Dynamic content

  • Add ARIA live regions
  • Implement focus management in modals
  • Test with screen reader

Week 4: Testing and documentation

  • Set up automated a11y tests
  • Document patterns in component library
  • Create accessibility statement page

Resources to go deeper#


Accessibility is a journey#

You don't need to be perfect on day one. Start with the basics:

  1. Use semantic HTML
  2. Make it keyboard accessible
  3. Add text alternatives
  4. Test with real users

Every improvement makes your product better for everyone. That's worth celebrating.

What accessibility feature are you most excited to implement? Let's build a more inclusive web together.