Accessibility
RetailOS targets WCAG 2.1 AA compliance across all web and mobile applications. This page documents concrete requirements and implementation details.
Color Contrast
All text-on-background combinations must meet minimum contrast ratios.
Light Mode Contrast Results
| Combination | Ratio | AA (Normal) | AA (Large) |
|---|---|---|---|
text-1 (#1A1816) on bg-body (#FAFAF7) | 15.8:1 | Pass | Pass |
text-1 (#1A1816) on bg-card (#FFFFFF) | 17.4:1 | Pass | Pass |
text-2 (#57534E) on bg-body (#FAFAF7) | 6.9:1 | Pass | Pass |
text-2 (#57534E) on bg-card (#FFFFFF) | 7.5:1 | Pass | Pass |
text-3 (#A8A29E) on bg-body (#FAFAF7) | 3.2:1 | Fail | Pass |
text-3 (#A8A29E) on bg-card (#FFFFFF) | 3.5:1 | Fail | Pass |
Brand (#2D6B4A) on bg-card (#FFFFFF) | 5.2:1 | Pass | Pass |
Brand (#2D6B4A) on brand-light (#E8F0EC) | 4.8:1 | Pass | Pass |
| White (#FFFFFF) on Brand (#2D6B4A) | 5.2:1 | Pass | Pass |
Danger (#C4463A) on bg-card (#FFFFFF) | 4.6:1 | Pass | Pass |
Warning (#D4910A) on bg-card (#FFFFFF) | 3.8:1 | Fail | Pass |
text-3 usage
text-3 (#A8A29E) does NOT pass AA for normal text (14px). Use it ONLY for:
- Placeholder text (not required to pass)
- Disabled state text (exempt from contrast requirements)
- Large text (18px+ or 14px+ bold)
For all other secondary text, use text-2.
Dark Mode Contrast Results
| Combination | Ratio | AA (Normal) |
|---|---|---|
text-1 (#F5F3EF) on bg-body (#1C1A17) | 14.2:1 | Pass |
text-2 (#B5B0A8) on bg-body (#1C1A17) | 7.8:1 | Pass |
text-3 (#6E6A64) on bg-body (#1C1A17) | 3.4:1 | Fail |
Brand Dark (#4A9E72) on bg-body (#1C1A17) | 4.8:1 | Pass |
Brand Dark (#4A9E72) on bg-card (#262420) | 4.3:1 | Fail (borderline) |
Keyboard Navigation
Focus Ring
All interactive elements must show a visible focus indicator:
*:focus-visible {
outline: 3px solid var(--color-brand);
outline-offset: 2px;
border-radius: var(--radius-sm);
}- Color: Brand green (
#2D6B4Alight /#4A9E72dark) - Width: 3px
- Offset: 2px from element edge
- Shape: follows element border-radius
Tab Order Requirements
| Context | Expected Tab Order |
|---|---|
| Sidebar | Skip nav link, then each nav item top-to-bottom |
| Data table | Column headers (sortable), then row-by-row left-to-right |
| Modal/Dialog | Focus trapped inside; first focusable element on open; Escape closes |
| Form | Label-input pairs in visual order |
| Stat cards | Not focusable (informational only) |
Keyboard Shortcuts
| Pattern | Key | Action |
|---|---|---|
| Modal | Escape | Close modal |
| Dropdown | Enter / Space | Open/select |
| Dropdown | Arrow Up/Down | Navigate options |
| Tab bar | Arrow Left/Right | Switch tabs |
| Date navigation | Arrow Left/Right | Previous/next day |
| Data table | Enter on header | Toggle sort |
ARIA Labels
Required ARIA Attributes
| Component | ARIA Requirement |
|---|---|
| Icon-only buttons | aria-label describing action |
| Status badges | role="status" or descriptive text |
| Sortable columns | aria-sort="ascending" / "descending" / "none" |
| Expandable sections | aria-expanded, aria-controls |
| Loading states | aria-busy="true", aria-live="polite" |
| Toast notifications | role="alert", aria-live="assertive" |
| Modals | role="dialog", aria-modal="true", aria-labelledby |
| Sidebar nav | role="navigation", aria-label="Navigasi utama" |
| Progress bars | role="progressbar", aria-valuenow, aria-valuemin, aria-valuemax |
| Toggle switches | role="switch", aria-checked |
Example: Icon Button
// Correct
<button aria-label="Hapus item" onClick={onDelete}>
<Trash className="w-4 h-4" />
</button>
// Incorrect — missing label
<button onClick={onDelete}>
<Trash className="w-4 h-4" />
</button>Example: Data Table Sort
<th
role="columnheader"
aria-sort={sortDirection === 'asc' ? 'ascending' : sortDirection === 'desc' ? 'descending' : 'none'}
tabIndex={0}
onClick={toggleSort}
onKeyDown={e => e.key === 'Enter' && toggleSort()}
>
Tanggal
</th>Screen Reader Considerations
Live Regions
Use aria-live for dynamic content updates:
{/* Toast container */}
<div aria-live="assertive" aria-atomic="true" className="sr-only">
{toastMessage}
</div>
{/* Loading state */}
<div aria-live="polite" aria-busy={isLoading}>
{isLoading ? 'Memuat data...' : content}
</div>Skip Navigation
Every page must include a skip-to-content link as the first focusable element:
<a href="#main-content" class="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-brand focus:text-white focus:rounded-lg">
Langsung ke konten utama
</a>Visually Hidden Text
For context that screen readers need but sighted users can infer:
.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;
}Touch Targets (Mobile)
All interactive elements on mobile must meet minimum touch target sizes:
| Element | Minimum Size | Recommended |
|---|---|---|
| Buttons | 44x44px | 48x48px |
| Icon buttons | 44x44px | 44x44px (with padding) |
| List items (tappable) | 44px height | 48px height |
| Checkboxes | 44x44px tap area | 24px visual, 44px tap |
| Toggle switches | 44x24px visual | 44x44px tap area |
| Tab bar items | 44px height | Full tab width |
Tap area vs visual size
The visual element can be smaller than 44px, but the tap target (via padding or transparent hit area) must be at least 44x44px.
Reduced Motion
Respect the prefers-reduced-motion media query:
@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;
}
}Elements affected:
| Animation | Default | Reduced Motion |
|---|---|---|
| Page transitions | 300ms slide | Instant |
| Hover scale effects | 150ms scale | No scale |
| Skeleton pulse | Continuous | Static gray |
| Member card tilt | Gyroscope-driven | Static |
| Toast slide-in | 200ms slide | Instant appear |
| PIN dot fill | Scale animation | Instant fill |
| Progress bars | Smooth transition | Instant |
React Native
import { AccessibilityInfo } from 'react-native'
const [reduceMotion, setReduceMotion] = useState(false)
useEffect(() => {
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion)
const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', setReduceMotion)
return () => sub.remove()
}, [])Testing Tools
| Tool | Platform | Purpose |
|---|---|---|
| axe DevTools | Chrome extension | Automated WCAG checks |
| Lighthouse | Chrome DevTools | Accessibility audit score |
| VoiceOver | macOS / iOS | Screen reader testing |
| TalkBack | Android | Screen reader testing |
| Contrast Checker | WebAIM | Manual contrast verification |
| Tab key | All | Keyboard navigation testing |
Required before launch
Every new screen or component must pass:
- axe DevTools scan with zero critical/serious violations
- Full keyboard-only navigation test
- VoiceOver or TalkBack walkthrough for key flows
- Color contrast verification for all text