A11y — Accessibility
WCAG 2.2 AAA patterns. All examples assume RTL (Hebrew/Arabic) unless noted as LTR-only.
WCAG 2.2 Critical Rules
| Criterion | Level | Requirement | Common Failure |
|---|---|---|---|
| 1.4.11 Non-text Contrast | AA | 3:1 ratio for UI components and focus indicators | Gray icons on white backgrounds |
| 1.4.12 Text Spacing | AA | Line height ≥1.5×, letter spacing ≥0.12×, word spacing ≥0.16× | Fixed pixel heights that clip text |
| 2.4.7 Focus Visible | AA | Focus indicator must be visible | outline: none without replacement |
| 2.4.11 Focus Not Obscured | AA | Focused element not entirely hidden by sticky header | Sticky nav covering focused inputs |
| 2.4.12 Focus Not Obscured | AAA | Focused element not partially hidden | |
| 2.5.3 Label in Name | A | Accessible name must contain visible label text | Icon buttons without aria-label |
| 2.5.8 Target Size | AA | Interactive targets minimum 24×24px; recommended 44×44px | Small icon-only buttons |
| 3.2.6 Consistent Help | A | Help mechanisms in consistent location across pages | |
| 3.3.7 Redundant Entry | A | Don't ask for same info twice | Re-asking for email on multi-step form |
| 3.3.8 Accessible Authentication | AA | No cognitive function test for auth | CAPTCHAs without alternative |
Rule: The 44×44px touch target minimum applies to all interactive elements on touch devices. (BLOCKING for mobile-first apps)
Touch Target Size
/* global.css — apply to all interactive elements */
button,
[role="button"],
a,
input[type="checkbox"],
input[type="radio"],
select {
/* Ensure minimum touch target without changing visual size */
min-height: 44px;
min-width: 44px;
}
/* For icon-only buttons that must appear smaller visually */
.icon-btn {
position: relative;
/* Visual size: 24px */
width: 24px;
height: 24px;
}
.icon-btn::before {
content: '';
position: absolute;
/* Touch target: 44px */
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
}
// Tailwind utility for 44px touch targets
// Add to your component library:
// className="relative w-6 h-6 before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-11 before:h-11"
Focus Trap Pattern
Required for: modals, drawers, date pickers, dropdown menus, mobile nav.
// hooks/useFocusTrap.ts
import { useEffect, useRef } from 'react'
const FOCUSABLE_SELECTORS = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ')
export function useFocusTrap(isActive: boolean) {
const containerRef = useRef<HTMLElement>(null)
const previousFocusRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!isActive) return
// Save currently focused element
previousFocusRef.current = document.activeElement as HTMLElement
const container = containerRef.current
if (!container) return
// Focus first focusable element
const focusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
const first = focusable[0]
const last = focusable[focusable.length - 1]
first?.focus()
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Tab') return
const currentFocusable = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS)
const firstEl = currentFocusable[0]
const lastEl = currentFocusable[currentFocusable.length - 1]
if (e.shiftKey) {
// Shift+Tab: wrap from first to last
if (document.activeElement === firstEl) {
e.preventDefault()
lastEl?.focus()
}
} else {
// Tab: wrap from last to first
if (document.activeElement === lastEl) {
e.preventDefault()
firstEl?.focus()
}
}
}
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
// Caller is responsible for closing — just restore focus
previousFocusRef.current?.focus()
}
}
container.addEventListener('keydown', handleKeyDown)
document.addEventListener('keydown', handleEscape)
return () => {
container.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keydown', handleEscape)
// Restore focus when trap is deactivated
previousFocusRef.current?.focus()
}
}, [isActive])
return containerRef
}
// Usage:
function Modal({ isOpen, onClose, children }) {
const trapRef = useFocusTrap(isOpen)
return (
<div
ref={trapRef as React.RefObject<HTMLDivElement>}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{children}
</div>
)
}
ARIA for Loading and Error States
// Loading state — announce to screen readers
function LoadingButton({ isLoading, children, onClick, ...props }) {
return (
<button
disabled={isLoading}
aria-busy={isLoading}
aria-live="polite"
{...props}
>
{isLoading ? (
<>
<span className="sr-only">טוען...</span>
<Spinner aria-hidden="true" />
</>
) : children}
</button>
)
}
// Error state — live region for async errors
function FormError({ error }: { error: string | null }) {
return (
<div
role="alert" // assertive by default — interrupts screen reader
aria-live="assertive"
aria-atomic="true"
className={error ? 'text-red-600' : 'sr-only'}
>
{error ?? ''}
</div>
)
}
// Success state — polite announcement
function SuccessMessage({ message }: { message: string | null }) {
return (
<div
role="status" // polite by default
aria-live="polite"
aria-atomic="true"
className={message ? 'text-green-600' : 'sr-only'}
>
{message ?? ''}
</div>
)
}
// Data table with loading state
function DataTable({ isLoading, data }) {
return (
<div aria-busy={isLoading} aria-label="טבלת נתונים">
{isLoading && <span className="sr-only">טוען נתונים...</span>}
<table>
{/* ... */}
</table>
</div>
)
}
RTL and lang Attribute
// app/layout.tsx — Root layout MUST declare lang and dir
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="he" dir="rtl">
<body>{children}</body>
</html>
)
}
// For mixed LTR content (URLs, code, numbers in Hebrew text):
function MixedContent() {
return (
<p>
כתובת האתר היא{' '}
<span lang="en" dir="ltr">
https://example.com
</span>
</p>
)
}
// Phone numbers — always LTR even in RTL context
function PhoneNumber({ phone }: { phone: string }) {
return (
<span dir="ltr" aria-label={`מספר טלפון: ${phone}`}>
{phone}
</span>
)
}
Rules:
<html lang="he" dir="rtl">is mandatory for Hebrew apps. (BLOCKING)- Inline
dir="ltr"for: URLs, phone numbers, email addresses, code, numbers in RTL context. (WARN) - Every page-level change must update
langif the language changes.
Skip Links
// components/SkipLinks.tsx — must be first element in <body>
export function SkipLinks() {
return (
<div className="sr-only focus-within:not-sr-only">
<a
href="#main-content"
className="absolute top-0 right-0 z-50 rounded-md bg-blue-600 p-3 text-white focus:outline-none focus:ring-2 focus:ring-white"
>
דלג לתוכן הראשי
</a>
<a
href="#main-nav"
className="absolute top-0 right-32 z-50 rounded-md bg-blue-600 p-3 text-white focus:outline-none focus:ring-2 focus:ring-white"
>
דלג לניווט
</a>
</div>
)
}
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="he" dir="rtl">
<body>
<SkipLinks />
<nav id="main-nav" aria-label="ניווט ראשי">...</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</body>
</html>
)
}
Reduced Motion
/* global.css */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// hooks/useReducedMotion.ts
export function useReducedMotion(): boolean {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
window.matchMedia('(prefers-reduced-motion: reduce)').matches
)
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return prefersReducedMotion
}
// Usage with framer-motion
function AnimatedCard({ children }) {
const prefersReducedMotion = useReducedMotion()
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: prefersReducedMotion ? 0 : 0.3 }}
>
{children}
</motion.div>
)
}