Creditra commits to WCAG 2.1 AA conformance, with selected 2.2 AAA criteria (target size 44 px) adopted as defaults. This document is both the policy and the implementation reference.
The frontend is reviewed for accessibility on every PR. The checklist in the root README is the bare minimum; the per-pattern guidance below is how individual components are expected to behave.
- AA is the legal baseline under most public-procurement, EU, and US accessibility regulations. Anything less is a future liability.
- AAA is component-by-component achievable, not platform-wide — for example, AAA contrast (7:1) breaks data-density on the Transactions table without an alternate view. We hold the line at AA but cherry-pick AAA where it costs nothing (target size, focus rings).
- The protocol handles money. A user who cannot reliably operate the repay flow is a user we have actively harmed. Accessibility is not a marketing checkbox here.
The four POUR principles drive every decision:
- Perceivable — every signal has a non-color form (glyph, text, ARIA).
- Operable — every action is keyboard-reachable in a logical order.
- Understandable — errors are inline, specific, and recoverable.
- Robust — semantic HTML first; ARIA only when no native element fits.
Every modal must compose all three a11y hooks:
| Hook | File | Role |
|---|---|---|
useFocusTrap |
src/hooks/useFocusTrap.ts |
Tab/Shift+Tab cycling, Escape close, return-focus to trigger |
useBodyScrollLock |
src/hooks/useBodyScrollLock.ts |
Freeze background scroll, preserve scroll position |
useInertBackdrop |
src/hooks/useInertBackdrop.ts |
inert (or aria-hidden fallback) on everything outside the modal |
The canonical example is src/components/WalletConnectionModal.tsx. The modal container
also sets role="dialog", aria-modal="true", and aria-labelledby pointing at the
heading.
- Column headers are
<th scope="col">with a<button>child for sortable columns; pressing Enter/Space toggles sort. aria-sort="ascending|descending|none"is set on the active column header.- Row order is the visual order; no reordering of DOM relative to layout.
- Filter chips are styled
<button>s witharia-pressedreflecting the toggle state. - Notification filters use the WAI-ARIA tab pattern: the group has
role="tablist", each filter hasrole="tab", the active filter setsaria-selected="true", and Arrow/Home/End keys move focus and selection.
- Every input is wrapped by
<FormField>(src/components/FormField.tsx) which programmatically wireshtmlFor↔id, setsaria-describedbyto a space-separated list of help + error IDs, setsaria-invalidon error, and emitsaria-requiredwhen marked required. - Error messages are rendered through
<FormMessage>. The visible message updates immediately, while the live alert announcement is debounced by 300 ms so assistive technology hears the settled validation state instead of every intermediate keystroke. - Inline validation does not block typing; it transitions the message tone from
info→success/warning/dangerpergetDrawAmountValidationinsrc/utils/amountValidation.ts. - Submit buttons use
<PendingButton>soaria-busy="true"is set during the request and the label changes (Submit→Submitting…) to communicate state.
- Trigger has
aria-haspopup="true",aria-expanded={open}, and anaria-labeldescribing what opens. - Menu items use
role="menuitem". - Escape closes; click-outside closes; focus returns to the trigger.
- Example:
WalletButton.tsxconnected state —aria-haspopupandaria-expandedon the address chip,role="menu"on the dropdown.
StatusBadgepairs a tinted pill with a single-letter glyph (A | ! | X | C). Color is never the sole signal.- Risk gauge uses
<text>SVG nodes for the score and a separate<text>for the trend arrow (▲ | ▼ | ─) plus the trend word as a sibling element so screen readers don't miss it.
| Use | Politeness | Component |
|---|---|---|
| Form field errors | role="alert" (assertive, debounced 300 ms) |
FormMessage |
| Copy-to-clipboard success | aria-live="polite" |
CopyToClipboard |
| Route changes | role="status" aria-live="polite" |
RouteAnnouncer |
| Browser connectivity (header) | Assertive on offline; polite on restore | NetworkStatus |
| Post-action confirmation | role="status" aria-live="polite" |
SuccessState |
| Toast notifications | Polite ToastContainer live region for confirmations; individual error toasts escalate to role="alert" |
ToastContainer |
- Global
:focus-visiblerule insrc/index.cssisoutline: 2px solid var(--accent); outline-offset: 2px. - Active nav links keep focus styling distinct from active styling (see the comment block
around
.header-nav-link.activeinsrc/index.css). - Modal close returns focus to the trigger via
useFocusTrap'striggerRef.
HelpCenter.tsx uses an anchor-link sidebar with aria-current="true" on the link whose
target section intersects the viewport.
- The active section is detected via
IntersectionObserver(src/hooks/useActiveSection.ts). The observer uses a-80px 0px -60% 0pxroot margin so the active link updates slightly before the section reaches the top of the viewport, and a multi-threshold[0, 0.25, 0.5, 0.75, 1]so the most-visible section wins when multiple overlap. - Only one nav link carries
aria-current="true"at any time. The attribute is absent on all other links. - Clicking an anchor calls
target.scrollIntoView({ behavior: 'smooth', block: 'start' }). Reduced-motion state is read fromuseReducedMotion()— when active, behavior switches to"instant". - The
<nav>hasaria-label="Help topics". Links are real<a href="#id">elements — keyboard navigable via Tab, activatable via Enter/Space, and receive the global:focus-visiblering fromsrc/index.css.
The table below is updated on every accessibility-impacting PR. Status legend: OK = audited and passing, TODO = known gap with target fix below.
| Component | Keyboard | ARIA | Contrast | Motion | Status |
|---|---|---|---|---|---|
WalletButton |
Tab/Enter/Esc; trigger has aria-haspopup/aria-expanded |
aria-label on icon-only states |
AA | n/a | OK |
WalletConnectionModal |
Focus trap + return; Escape closes | role="dialog", aria-modal, aria-labelledby |
AA | reduced-motion gated | OK |
ShortcutHelpOverlay |
Global ? trigger outside text inputs; Escape closes; focus returns |
role="dialog", aria-modal, grouped shortcut lists |
AA | reduced-motion gated | OK |
OnboardingFlow |
Arrow keys advance/back; Esc skips | Stepper labelled via aria-label |
AA | useReducedMotion() |
OK |
FormField |
Native input semantics | Auto htmlFor, aria-describedby, aria-invalid, aria-required |
AA | n/a | OK |
FormMessage |
n/a (text only) | role="alert" on error |
AA | reduced-motion gated | OK |
AmountInput |
Native input + preset buttons; Tab in order | aria-describedby aggregates helper/constraint/status/error |
AA | n/a | OK |
PendingButton |
Disabled during pending; Enter submits | aria-busy="true" while pending |
AA | n/a | OK |
StatusBadge |
n/a (display) | aria-label="Credit line status: …" |
AA | n/a | OK |
Skeleton |
n/a | n/a | n/a | reduced-motion gated | OK |
CopyToClipboard |
Real <button>; Enter copies |
Specific aria-label; polite live region announces "Copied" |
AA | n/a | OK |
AccessibleTooltip |
Trigger is keyboard-focusable | role="tooltip", aria-describedby |
AA | n/a | OK |
RouteAnnouncer |
n/a (route observer) | Updates document.title, meta description, and a polite live region |
AA | n/a | OK |
NotificationBell |
Tab/Enter; counter is decorative | aria-label="Notifications, N unread" |
AA | n/a | OK |
NotificationCenter |
Focus trap inside the panel; mobile Expand/Collapse snap controls for keyboard users | role="dialog", category filters use role="tab" + aria-selected; iOS safe-area insets on bottom sheet |
AA | reduced-motion disables snap transitions | OK |
ToastContainer |
Tab/Esc to dismiss | role="status" / role="alert" per severity |
AA | reduced-motion gated | OK |
BannerAlert |
Tab/Enter on action & dismiss | role="alert" for warning/error |
AA | n/a | OK |
Dashboard (risk gauge) |
n/a | Score and trend exposed via <title> + polite sr-only sibling; arc animates on value change with reduced-motion fallback |
AA | reduced-motion gated (CSS + JS matchMedia) |
OK |
Header nav |
Tab through links; Enter activates | aria-current="page" on active link |
AA | n/a | OK |
RepayModal |
Focus trap (canonical { isActive } form) + return focus to trigger |
role="dialog", aria-modal, aria-labelledby |
AA | n/a | OK |
TransactionHistory |
Sortable headers via Enter/Space | aria-sort reflects column state |
AA | n/a | OK |
HelpCenter |
Tab/Enter on sidebar anchor links; accordion buttons and transcript links keyboard reachable | Sidebar nav has aria-label="Help topics"; aria-current="true" on active section via IntersectionObserver |
AA | useReducedMotion() gates smooth scroll |
OK |
SupportWidget |
Floating trigger, search field, FAQ toggles, and email handoff are keyboard reachable | aria-expanded, aria-controls, visible focus ring, non-modal role="dialog" shell |
AA | n/a | OK |
LandingPage |
Tab through CTAs and FAQ accordion | Framer Motion guarded by useReducedMotion |
AA | reduced-motion gated | OK |
ErrorBoundary / ErrorPage |
Tab through "Go back" and "Reload" | Semantic landmarks | AA | n/a | OK |
| ID | Component | Gap | Target |
|---|---|---|---|
| A11Y-001 | OnboardingFlow |
Arrow-key step navigation not wired (today uses Next/Back buttons only) | next minor release |
RepayModal |
useFocusTrap({ isActive }) |
Fixed — migrated to { isActive } form; triggerRef wired; regression test added |
|
| A11Y-003 | NotificationCenter |
Filter tabs use aria-pressed but should additionally expose role="tab" + aria-selected for AT consistency |
next minor release |
aria-sort is set but caption text describing the table is not yet announced |
Closed — <caption> added to TransactionHistory; <section aria-label> added to CreditLines; both update dynamically with filter state |
All interactive elements meet 44×44 CSS px, derived from:
- WCAG 2.5.5 (AAA, 44×44 recommended)
- WCAG 2.5.8 (AA in 2.2, 24×24 minimum with spacing)
- Apple HIG 44 pt minimum
- Material Design 48 dp recommended
Use min-width / min-height (not fixed) so labels can grow with content. The canonical
pattern:
.icon-btn {
min-width: 44px;
min-height: 44px;
padding: 0.625rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.compact-text-btn {
min-height: 44px;
padding: 0.625rem 0.5rem;
display: inline-flex;
align-items: center;
}| Component | Element | Before | After | Compliant |
|---|---|---|---|---|
NotificationBell |
.notif-bell |
~30×30 | 44×44 | yes |
WalletConnectionModal |
.close-btn |
32×32 | 44×44 | yes |
NotificationCenter |
.nc-icon-btn |
~24×24 | 44×44 | yes |
NotificationCenter |
.nc-close-btn |
~24×24 | 44×44 | yes |
NotificationCenter |
.nc-text-btn |
~20 h | 44 h | yes |
NotificationCenter |
.nc-filter-tab |
~20 h | 44 h | yes |
NotificationCenter |
.nc-item-action |
~20 h | 44 h | yes |
BannerAlert |
.banner-close |
~20×20 | 44×44 | yes |
BannerAlert |
.banner-action |
~20 h | 44 h | yes |
ToastContainer |
.toast-close |
~20×20 | 44×44 | yes |
TransactionHistory |
.export-btn |
~32 h | 44 h | yes |
Dashboard |
.wallet-address-chip |
~32 h | 44 h | yes |
WalletButton |
.connect-wallet-btn |
44 h | 44 h | yes (already) |
WalletButton |
.wallet-address-btn |
44 h | 44 h | yes (already) |
WalletButton |
.disconnect-btn |
44 h | 44 h | yes (already) |
network-badge, nc-badge, notif-bell-badge, status-badge, status-dot, progress
bars and utilization bars are informational, not interactive, and are exempt from the
target rule.
The shared CopyToClipboard component is the only sanctioned way to copy a wallet address
or transaction hash. Its contract:
- Renders a real
<button>(never a<div role="button">). - Pairs a visible
Copylabel with an icon; the icon isaria-hidden. - Provides a specific
aria-labellikeCopy connected wallet addressorCopy transaction hash for TX-001when the value itself is not fully visible. - On success, label flips to
CopiedforCOPY_FEEDBACK_DURATION_MS(2000 ms) and a polite live region announces the change to screen readers. - Keeps focus on the button so keyboard flow is unbroken.
Every animation in the codebase is gated by
@media (prefers-reduced-motion: reduce) and the equivalent [data-motion="reduced"] attribute.
For testing and verifying reduced-motion states without altering OS settings, use the Reduced Motion Preview toggle in the Settings page. This toggle sets [data-motion="reduced"] on the root <html> element, which disables animations globally across the app.
Inventory of CSS files with reduced motion overrides:
src/index.css— two top-level reduced-motion blockssrc/components/Skeleton.csssrc/components/OnboardingFlow.csssrc/components/WalletConnectionModal.csssrc/components/FormField.csssrc/components/LandingPage.css
JS-driven animations (Framer Motion) call useReducedMotion() from the context and switch to instant
state changes. The landing hero in src/components/LandingPage.tsx and risk gauge in src/components/RiskGauge.tsx are canonical examples.
| Tool | Use |
|---|---|
@testing-library/react + @testing-library/jest-dom + @testing-library/user-event |
Component and integration tests — query by role, simulate real keyboard events |
vitest |
Test runner |
jsdom |
DOM environment for tests (configured in vitest.config.ts) |
| axe-core / @axe-core/react | Recommended addition for automated CI scanning |
| Lighthouse (browser DevTools or CI) | Per-route accessibility audit, drives the score reported in PERFORMANCE.md |
| Manual screen-reader checks | VoiceOver (macOS), NVDA (Windows), TalkBack (Android) before any release that ships overlay or form changes |
Manual checklist before merging accessibility-impacting work:
- Disconnect the mouse; navigate the full flow with Tab/Shift+Tab/Enter/Space/Esc.
- Toggle
prefers-reduced-motionin OS settings; confirm no animation plays. - Toggle Forced Colors (Windows) or Increase Contrast (macOS); confirm nothing disappears.
- Run a screen reader through the changed surface and confirm errors, statuses, and live-region updates are announced.
Use this when adding or changing a UI surface. PRs that touch UI without this checklist are bounced.
- Native HTML element used wherever possible (
button,a,label,nav,header,main,dialog). - Every icon-only control has
aria-label. - Every form field uses
<FormField>or replicates its label/help/error wiring. - Color is never the sole signal — glyph, text, or icon backs it up.
- Touch targets ≥ 44×44 px.
- Focus is visible and logically ordered.
- Modals compose
useFocusTrap+useBodyScrollLock+useInertBackdrop. - All animation is gated by
prefers-reduced-motion. - Contrast meets AA against the surface it sits on (
COLOR.surfacefor cards,COLOR.bgfor the page). - Screen-reader announcements are routed through a live region with the right politeness.