Accessibility
Priority ladder from ui-skills/fixing-accessibility, with the finance-specific rules that generic guidance doesn't cover.
The framing that matters: in a financial product, an accessibility failure is frequently a correctness failure. A screen reader that drops the sign off (1,234.00) isn't giving a degraded experience — it's reading a different number.
Priority ladder
| P | Category | Impact |
|---|---|---|
| 1 | Accessible names | critical |
| 2 | Keyboard access | critical |
| 3 | Focus & dialogs | critical |
| 4 | Semantics | high |
| 5 | Forms & errors | high |
| 6 | Announcements | medium-high |
| 7 | Contrast & states | medium |
| 8 | Media & motion | low-medium |
Fix in order. A perfectly contrasted button nobody can reach with a keyboard is worth nothing.
1. Accessible names
- Every interactive control has an accessible name.
- Icon-only buttons require
aria-label. The most common failure in dense financial toolbars, where a row can have five icon buttons. - Every
input/select/textareahas a real<label>. A placeholder is not a label — it disappears the moment someone types, which in an amount field is exactly when they need it. - Links have meaningful text. Never "click here", never "view".
- Decorative icons are
aria-hidden="true".
Finance-specific
- A table row that's clickable needs a name describing the row, not "row".
aria-label="Transaction: Northwind Ltd, 1,240 dollars, 3 September". - A masked account is named by its meaning:
aria-label="Checking account ending 4291", with the••••itselfaria-hidden. - A status pill's name includes the entity:
"Payment pending"not"Pending".
2. Keyboard access
- Never a
divorspanas a button without full keyboard support. Use<button>. - Everything interactive is reachable by
Tab. - Focus is always visible.
- Never
tabindex > 0. Escapecloses dialogs, sheets, popovers, and cancels an in-progress inline edit.
Data grid keyboard model
A financial table is a grid, and power users navigate it with arrow keys. This is non-optional for the primary transaction table.
| Key | Action |
|---|---|
↑ ↓ ← → |
Move cell focus |
Home / End |
First / last cell in row |
Cmd+↑ / Cmd+↓ |
First / last row |
Space |
Toggle row selection |
Shift+↑↓ |
Extend selection |
Enter |
Open the row's detail |
Escape |
Clear selection, then close detail |
Tab |
Move out of the grid, not through every cell |
That last one is the rule people miss: a 12-column × 200-row grid has 2,400 tab stops if you get it wrong. The grid is one tab stop; arrows navigate within it. Use roving tabindex (or aria-activedescendant) — or better, use a primitive that implements it.
Shortcuts
Every keyboard shortcut is discoverable (a ? overlay), overridable, and never conflicts with a browser or assistive-tech shortcut. And per the frequency law, every keyboard-initiated action is motionless.
3. Focus & dialogs
- Dialogs trap focus while open.
- Focus returns to the trigger on close. Always. A user who opens a row detail from row 40 and closes it must land back on row 40, not at the top of the table.
- Initial focus is set inside the dialog — on the first interactive element, or on the dialog itself if the content is long. Never on a destructive action.
- Opening a dialog doesn't scroll the page underneath.
- Scroll lock preserves scroll position and compensates for scrollbar width, or the whole page shifts sideways when a dialog opens.
Never hand-roll
Use Base UI (preferred) or Radix for dialogs, menus, comboboxes, tabs, popovers, tooltips. Focus trapping, aria-activedescendant, typeahead, and the twenty edge cases in a combobox are solved problems. Rebuilding them by hand is how products ship broken keyboard support.
Never mix primitive systems in one interaction surface. A Radix dialog containing a Base UI select will fight over focus.
4. Semantics
- Native elements before roles.
<button>,<a>,<input>,<table>. - If a role is used, its required ARIA attributes are present.
- Lists are
<ul>/<ol>with<li>. - Heading levels don't skip.
- Tables use real table semantics —
<table>,<thead>,<th scope="col">,<tbody>. Adivgrid withrole="grid"is a last resort for virtualization, and then every ARIA attribute must be right.
Financial statement semantics
A P&L is a table with hierarchy, and getting this right is what makes it usable non-visually:
<table>
<caption>Profit and loss, 1 Jan – 30 Sep 2026</caption>
<thead>
<tr><th scope="col">Account</th>
<th scope="col">Sep 2026</th>
<th scope="col">Aug 2026</th>
<th scope="col">Change</th></tr>
</thead>
<tbody>
<tr><th scope="rowgroup" colspan="4">Revenue</th></tr>
<tr><th scope="row">Product revenue</th><td>482,100</td>…</tr>
<tr class="subtotal"><th scope="row">Total revenue</th><td>600,500</td>…</tr>
</tbody>
</table>
<th scope="row">on account names — so a screen reader announces "Product revenue, September 2026, 482,100" when reading a cell, instead of a naked number.<caption>states the statement and the resolved period.- Hierarchy depth via
data-levelon the row header, plus visual indentation — and the depth said in words where it matters ("Total current assets"). Notaria-level: on a plain table's cells and rows it is prohibited ARIA (it belongs totreegrid), and assistive tech ignores it there. - Expandable rows:
aria-expandedon the toggle,aria-controlspointing at the child rows. - Subtotal and total rows are announced as such — put the word in the row header ("Total revenue"), don't rely on the rule line, which is invisible to a screen reader.
5. Forms & errors
- Errors linked to their field with
aria-describedby. - Invalid fields set
aria-invalid="true". - Required fields announced (
required, not just an asterisk). - Helper text associated via
aria-describedby. - A disabled submit explains why. A greyed-out "Send payment" with no reason is a dead end — either enable it and show the error on submit, or state the blocker next to the button.
- Never block paste in any input. Users paste account numbers, amounts, and reference codes constantly, and blocking paste in an IBAN field is actively hostile.
The amount input
The hardest input in fintech. Full spec in payments; the accessibility requirements:
inputMode="decimal"for the right mobile keypad.type="text", nottype="number"— number inputs have scroll-wheel increment (which will change someone's payment amount by accident), inconsistent decimal handling across locales, and hidden spinners.- The currency is part of the accessible name:
aria-label="Amount in US dollars". - Announce the parsed value on blur so the user hears what was understood:
aria-describedby→"One thousand two hundred and thirty-four dollars and fifty-six cents". - Errors are specific and actionable:
"Enter an amount of $0.01 or more","Amount exceeds available balance of $4,120.00".
6. Announcements
- Critical form errors use
aria-live="assertive"; everything elsepolite. - Loading uses
aria-busyor explicit status text. - A toast is never the only channel for critical information. Toasts are timed and dismissible; a failed payment must also persist inline.
- Expandable controls use
aria-expanded+aria-controls.
Live financial data
- The container is
aria-live="polite", not each cell. - Throttle announcements. A live rate updating at 2Hz would announce 7,200 times an hour. Announce at most every 10 seconds, or on a meaningful threshold crossing.
- Offer a way to turn live announcements off entirely. Some users need it silent.
- The value flash is a visual affordance; the live region is its non-visual peer. Both, always.
7. Contrast & states
Full table in color. The rules that get missed:
- Money is 7:1 — our own stricter rule.
- Hover-only interactions have a keyboard equivalent. Row actions revealed on hover must be reachable by focusing the row.
- Disabled state never relies on color alone. Add
aria-disabledand, where the reason isn't obvious, text. - Focus outlines are never removed without a visible replacement.
- Sign is never conveyed by color alone. The greyscale test is the ship gate.
Forced colors mode
Windows High Contrast overrides your palette entirely. Consequences:
bordersurvives;box-shadowdoes not. Since our surfaces are border-first, we're mostly fine — but any element relying only on a shadow for its edge disappears.- Use
forced-colors: activeto restore what's lost:@media (forced-colors: active) { .card { border: 1px solid CanvasText; } .flash { forced-color-adjust: none; } /* keep the tick legible */ .focused { outline: 2px solid Highlight; } } - Semantic color is gone in forced colors — which is exactly why sign is never color-only.
8. Media & motion
- Images have correct
alt: meaningful when informative,alt=""when decorative. Isometric empty-state illustrations are decorative —alt=""— because the adjacent text carries the message. prefers-reduced-motionrespected for all non-essential motion. See motion principles.- No autoplaying media with sound. No sound at all in this system.
Zoom and reflow
WCAG 1.4.10: content must reflow at 320px equivalent width (400% zoom on a 1280px viewport) without two-dimensional scrolling.
A 12-column financial table cannot literally reflow into one column and remain a table. The compliant approach:
- Data tables are the sanctioned exception to the no-2D-scroll rule — but the page around them must reflow, and the table's own horizontal scroll must be keyboard-operable with a visible focus indicator on the scroll container.
- Below 640px, the table becomes a card list (see responsive) — which is a genuine reflow, not a compromise.
- Text scales to 200% without loss. Which means no fixed-height containers around text,
remfor type, and nooverflow: hiddenon a text container that could grow.
Testing
Automated (CI, blocking):
axe-coreon every component storyeslint-plugin-jsx-a11y- Contrast assertions computed from the token file — a token change that breaks contrast fails the build
Manual (per component, before stable):
- Full keyboard pass with no mouse
- VoiceOver (Safari) + NVDA (Firefox) on every interactive component
- 200% zoom
- Forced colors mode
prefers-reduced-motion: reduce- The greyscale test — screenshot in greyscale; every sign must still be readable
Automated tools catch ~30% of issues. The manual list is the real gate.