WCAG 2.1 AA is the minimum bar for all shipped components. AAA is the target where achievable.
Requirements for All Components
- Color is never the only means of conveying information
- All interactive elements reachable by keyboard (Tab, Shift+Tab)
- Focus indicator always visible — never
outline: nonewithout a custom replacement - Touch targets minimum 44×44px on mobile
- Text contrast ≥ 4.5:1 (normal text), ≥ 3:1 (large text ≥18pt or 14pt bold)
- Non-text UI contrast ≥ 3:1 (borders, icons, focus rings)
- All images have descriptive alt text or alt="" if purely decorative
- Page has a logical heading hierarchy (H1 → H2 → H3)
Contrast Ratio Reference
| Combination | Foreground | Background | Ratio | Result |
|---|---|---|---|---|
| Primary button text | #FFFFFF | #1955A6 | 4.8:1 | ✓ Pass |
| Primary button hover | #FFFFFF | #0B2F5C | 9.2:1 | ✓ Pass |
| Body text | #383838 | #FFFFFF | 9.7:1 | ✓ Pass |
| Helper text | #909090 | #FFFFFF | 3.7:1 | ⚠ Note |
| Error text | #C62828 | #FFFFFF | 5.5:1 | ✓ Pass |
| Dark navbar text | #FFFFFF | #0B2F5C | 9.2:1 | ✓ Pass |
| Brand bg text | #1955A6 | #E7EFF7 | 4.2:1 | ✓ Pass |
| Disabled text | #B0B0B0 | #FFFFFF | 2.1:1 | ⚠ Note |
Helper text and disabled text intentionally fall below 4.5:1 — they are supplemental and non-interactive. This is a deliberate system decision, not a bug.
Input
- Every input has a visible
<label>— never use placeholder as label - Error state: aria-invalid='true' + aria-describedby → error message
- Helper text: linked via aria-describedby
- Required fields: aria-required='true' + visual indicator (not color alone)
- Focus ring: 2px brand-primary border on :focus-visible
Visual Example
We'll never share your email.
Password must be at least 8 characters.
Checkbox
- Native
<input type='checkbox'>or role='checkbox' + aria-checked - Indeterminate state: aria-checked='mixed'
- Group: wrap in
<fieldset>with<legend> - Click target minimum 24×24px
Visual Example
Radio
- Group wrapped in
<fieldset>with<legend>describing the question - Use native
<input type='radio'>inside the group - Arrow keys navigate within group — native browser behavior
- Group error: aria-invalid on the fieldset
Visual Example
Toggle
- role='switch' + aria-checked='true/false'
- Visible label always present alongside toggle
- State announced on change via live region or updated aria-label
- Knob vs track contrast ≥ 3:1
Visual Example
Modal
- Focus trapped inside modal when open (Tab/Shift+Tab cycle within)
- Focus moves to modal header on open; returns to trigger on close
- role='dialog' + aria-modal='true' + aria-labelledby → modal title
- Escape key closes modal
- Backdrop click closes modal
- Scrollable content: overflow-y: auto on modal-body
Visual Example
Dropdown
- Trigger: aria-haspopup='listbox' + aria-expanded='true/false'
- Menu: role='listbox' or role='menu'
- Items: role='option' or role='menuitem'
- Arrow keys navigate items; Enter selects; Escape closes
- Selected item: aria-selected='true'
Visual Example
Newest first
Oldest first
Alphabetical
TabBar
- role='tablist' on container; role='tab' on each tab; role='tabpanel' on content
- Active tab: aria-selected='true'; all others aria-selected='false'
- Tab panel: aria-labelledby pointing to its tab id
- Arrow keys navigate tabs (left/right within tablist)
- Disabled tab: aria-disabled='true'
Visual Example
Tab panel content for Dashboard
Alert
- role='alert' for error/warning (live region, assertive announce)
- role='status' for info/success (live region, polite announce)
- Dismiss button: aria-label='Dismiss alert'
- Icon is decorative: aria-hidden='true'
Visual Example
Error: Could not save changes. Please try again.
Success: Your changes have been saved.
Warning: You're approaching your storage limit.
Badge
- Decorative badges: aria-hidden='true'
- Meaningful (counts, status): provide text alternative in context
- Count badges in tabs: tab label should include count, e.g. 'All (128)'
Visual Example
Active
Complete
Failed
Warning
Inactive
DataTable
- Use
<table>element with<th scope='col'>for column headers - Sortable columns: aria-sort='ascending / descending / none'
- Table caption or aria-label on the
<table> - Loading state: aria-busy='true' on the table
- Empty state: descriptive message in
<tbody> - Row actions: aria-label includes row context
Visual Example
| Name ↑ | Status | Date |
|---|---|---|
| Acme Rebrand | Active | Mar 10 |
| Mobile App | Review | Feb 28 |
Avatar
- Decorative: aria-hidden='true' on the entire element
- Interactive (links to profile): aria-label='View [Name]'s profile'
- Text initials: aria-hidden='true' on the text element
Visual Example
Card
- Interactive cards: wrap in
<a>or<button> - Focus ring visible on interactive card wrapper
- Images inside: meaningful alt text or alt='' if decorative
Visual Example
Project
Acme Rebrand
Brand identity refresh across all touchpoints.
Active