Accessibility (a11y) is not optional: it helps keyboard users, screen‑reader users, and frankly everyone (think: low contrast screens, bright sun, fatigue). This part is beginner‑friendly and packed with commented patterns you can paste in today.
🎯 Goals
- Semantic HTML & landmarks (nav, main, header, footer)
- Keyboard support (Tab order, focus outlines, ESC to close)
- Visible focus with
:focus-visible - Skip link, visually hidden text, live regions
- Readable colors (contrast), reduced motion
- ARIA for dialog, tabs, and form errors
1) Baseline: semantic layout + skip link
Make the structure obvious to assistive tech and provide a quick jump to content.
// src/app/Layout.tsx
import { SkipLink } from '@/components/a11y/SkipLink';
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div>
<SkipLink target="#main" />
<header role="banner" className="p-4 border-b">
<h1 className="text-xl font-semibold">TaskTimer</h1>
<nav role="navigation" aria-label="Primary" className="mt-2">
{/* real links here */}
</nav>
</header>
<main id="main" role="main" className="p-4">
{children}
</main>
<footer role="contentinfo" className="p-4 border-t text-sm">© TaskTimer</footer>
</div>
);
}
// src/components/a11y/SkipLink.tsx
export function SkipLink({ target = '#main' }: { target?: string }) {
return (
<a
href={target}
className="sr-only focus:not-sr-only fixed top-2 left-2 bg-black text-white px-3 py-2 rounded"
>
Skip to content
</a>
);
}
Add a minimal screen‑reader utility class:
/* src/styles/a11y.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: 0; }
.not-sr-only { position: static; width: auto; height: auto; margin: 0; overflow: visible; clip: auto; white-space: normal; }
Include in index.css:
@import './styles/a11y.css';
2) Focus styles you can actually see
Never remove outlines. Style them.
/* src/styles/focus.css */
:root { --ring: #2563eb; --ring-offset: #ffffff; }
:root[data-theme="dark"] { --ring-offset: #0f172a; }
:focus { outline: none; }
:focus-visible { box-shadow: 0 0 0 3px var(--ring), 0 0 0 6px var(--ring-offset); border-radius: 8px; }
Include in index.css.
3) Accessible Button & Link reminders
- Use
<button>for actions,<a>withhreffor navigation. - Ensure name via visible text or
aria-label. - Disable with
disabled, not just CSS.
4) Dialog (Modal) — ARIA + focus trap + ESC
A modal must: announce itself, trap focus, restore focus on close, and close on ESC/backdrop.
// src/components/a11y/Modal.tsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
export function Modal({
open,
title,
children,
onClose,
id = 'modal-1',
}: {
open: boolean;
title: string;
children: React.ReactNode;
onClose: () => void;
id?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const lastActive = useRef<HTMLElement | null>(null);
// Mount/unmount side effects
useEffect(() => {
if (!open) return;
lastActive.current = (document.activeElement as HTMLElement) ?? null;
// Focus the dialog on open
ref.current?.focus();
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', onKey);
// Prevent background scroll while open
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', onKey);
// Restore focus to the trigger element
lastActive.current?.focus();
};
}, [open, onClose]);
// Simple focus trap (keeps Tab inside)
useEffect(() => {
if (!open) return;
const el = ref.current!;
const selectors = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])';
const getNodes = () => Array.from(el.querySelectorAll<HTMLElement>(selectors)).filter(n => !n.hasAttribute('disabled'));
const trap = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return;
const nodes = getNodes();
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
};
el.addEventListener('keydown', trap);
return () => el.removeEventListener('keydown', trap);
}, [open]);
if (!open) return null;
return createPortal(
<div
aria-hidden={!open}
className="fixed inset-0 bg-black/50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
tabIndex={-1}
className="bg-white text-black dark:bg-zinc-900 dark:text-white rounded-xl p-4 max-w-lg w-full"
>
<h2 id={`${id}-title`} className="text-lg font-semibold mb-2">{title}</h2>
<div>{children}</div>
<div className="mt-4 text-right">
<button className="underline" onClick={onClose}>Close</button>
</div>
</div>
</div>,
document.body,
);
}
Checks:
role="dialog"+aria-modal="true"+aria-labelledby.- Focus trap via keydown handler, ESC to close, backdrop click to close.
- Restores focus to the opener.
5) Tabs — roving focus + ARIA roles
// src/components/a11y/Tabs.tsx
import { useId, useState } from 'react';
export function Tabs({ labels, children }: { labels: string[]; children: React.ReactNode[] }) {
const [i, setI] = useState(0);
const base = useId();
return (
<div>
<div role="tablist" aria-label="Sample tabs" className="flex gap-2 border-b">
{labels.map((label, idx) => (
<button
key={label}
role="tab"
id={`${base}-tab-${idx}`}
aria-selected={i === idx}
aria-controls={`${base}-panel-${idx}`}
tabIndex={i === idx ? 0 : -1}
className={i === idx ? 'font-semibold border-b-2' : 'text-gray-500'}
onClick={() => setI(idx)}
onKeyDown={(e) => {
if (e.key === 'ArrowRight') setI((idx + 1) % labels.length);
if (e.key === 'ArrowLeft') setI((idx - 1 + labels.length) % labels.length);
}}
>
{label}
</button>
))}
</div>
{children.map((node, idx) => (
<div
key={idx}
role="tabpanel"
id={`${base}-panel-${idx}`}
aria-labelledby={`${base}-tab-${idx}`}
hidden={i !== idx}
className="py-3"
>
{node}
</div>
))}
</div>
);
}
Checks: role="tablist", each tab has role="tab" with aria-controls → panel has role="tabpanel" with aria-labelledby. Arrow keys switch focus.
6) Forms — labels, errors, and hints
// src/components/forms/Field.tsx
type FieldProps = {
id: string;
label: string;
hint?: string;
error?: string;
} & React.InputHTMLAttributes<HTMLInputElement>;
export function Field({ id, label, hint, error, ...props }: FieldProps) {
const hintId = hint ? `${id}-hint` : undefined;
const errId = error ? `${id}-error` : undefined;
const describedBy = [hintId, errId].filter(Boolean).join(' ') || undefined;
return (
<div className="mb-3">
<label htmlFor={id} className="block font-medium">{label}</label>
<input
id={id}
aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy}
className={`mt-1 w-full rounded border px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'}`}
{...props}
/>
{hint && <div id={hintId} className="text-sm text-gray-500 mt-1">{hint}</div>}
{error && <div id={errId} className="text-sm text-red-600 mt-1">{error}</div>}
</div>
);
}
Checks: label → input; aria-describedby chains hint + error; aria-invalid when invalid.
7) Announcements — live regions
For async success/error messages that appear dynamically.
// src/components/a11y/Live.tsx
export function LiveRegion({ message, polite = true }: { message: string; polite?: boolean }) {
return (
<div role="status" aria-live={polite ? 'polite' : 'assertive'} aria-atomic="true" className="sr-only">
{message}
</div>
);
}
Use <LiveRegion message="Task saved" /> after a form submit.
8) Color contrast & motion
- Pick colors that meet WCAG AA (use tooling: Stark, Accessible Brand Colors, or browser devtools).
- Respect users who prefer less motion.
/* src/styles/reduced-motion.css */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; transition: none !important; }
}
Include in index.css.
9) Linting & automated checks
- ESLint plugin:
eslint-plugin-jsx-a11y - Runtime hints (dev):
@axe-core/react
pnpm add -D eslint-plugin-jsx-a11y @axe-core/react
// .eslintrc.cjs (excerpt)
module.exports = {
plugins: ['jsx-a11y'],
extends: ['plugin:jsx-a11y/recommended'],
};
// src/main.tsx (dev only)
if (import.meta.env.DEV) {
import('@axe-core/react').then(({ default: axe }) => {
axe(React, ReactDOM, 1000);
});
}
✅ Wrap‑Up
You now have practical a11y scaffolding: semantic landmarks, visible focus, keyboard‑safe dialogs, ARIA‑correct tabs, form error wiring, live announcements, contrast and motion preferences — plus automated checks. Keep these patterns in your component library so accessibility is built‑in, not bolted‑on.
Comments
Post a Comment