Accessibility
The priority ladder from ui-skills/fixing-accessibility, plus the finance rules generic guidance misses.
In a financial product, an accessibility failure is often a correctness failure. A screen reader that drops the sign from (1,234.00) is 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 well-contrasted button nobody can reach by keyboard is worth nothing.
1. Accessible names#
- Every interactive control has an accessible name.
- Icon-only buttons require
aria-label. This is the most common failure in dense financial toolbars, where one row can hold five icon buttons. - Every
input/select/textareahas a real<label>. A placeholder isn't a label. It disappears once 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 move through it with arrow keys. The primary transaction table must support this.
| 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 |
People miss that last one. Get it wrong and a 12-column × 200-row grid has 2,400 tab stops. The grid is one tab stop, and arrows move within it. Use roving tabindex (or aria-activedescendant), or better, a primitive that already implements it.
Shortcuts#
Every keyboard shortcut is discoverable (a ? overlay), overridable, and clear of browser and assistive-tech shortcuts. Per the frequency law, every keyboard-initiated action is motionless.
3. Focus & dialogs#
- Dialogs trap focus while open.
- Focus always returns to the trigger on close. Open a row detail from row 40, close it, and you land back on row 40, not at the top of the table.
- Initial focus goes 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. Otherwise the page shifts sideways when a dialog opens.
Never hand-roll#
Use Base UI (preferred) or Radix for dialogs, menus, comboboxes, tabs, popovers and 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 has to be right.
Financial statement semantics#
A P&L is a table with hierarchy. Getting the markup right is what makes it usable without sight:
<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 reads a cell as "Product revenue, September 2026, 482,100" instead of a bare number.<caption>states the statement and the resolved period.- Hierarchy depth goes in
data-levelon the row header, plus visual indentation, with the depth said in words where it matters ("Total current assets"). Notaria-level: on a plain table's cells and rows it's 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 say so in the row header ("Total revenue"). The rule line 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 grayed-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 all day. Blocking paste in an IBAN field is hostile.
The amount input#
The hardest input in fintech. The full spec is in payments. For accessibility:
inputMode="decimal"for the right mobile keypad.type="text", nottype="number". Number inputs increment on scroll (changing someone's payment by accident), handle decimals inconsistently across locales, and hide 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 time out and can be dismissed. A failed payment also stays inline.
- Expandable controls use
aria-expanded+aria-controls.
Live financial data#
- The container is
aria-live="polite", not each cell. - Throttle announcements. A rate updating at 2Hz would announce 7,200 times an hour. Announce at most every 10 seconds, or when a meaningful threshold is crossed.
- Offer a way to turn live announcements off. Some users need silence.
- The value flash is the visual signal and the live region is its non-visual peer. Ship both.
7. Contrast & states#
The full table is in color. These are the rules that get missed:
- Money is 7:1. That's 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 grayscale test is the ship gate.
Forced colors mode#
Windows High Contrast replaces your palette entirely. What that means:
bordersurvives;box-shadowdoesn't. Our surfaces are border-first, so we're mostly fine, but anything that relies 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 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 text beside them 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 can't reflow into one column and stay a table. The compliant approach:
- Data tables are the sanctioned exception to the no-2D-scroll rule. The page around them still reflows, and the table's horizontal scroll works from the keyboard, with a visible focus indicator on the scroll container.
- Below 640px, the table becomes a card list (see responsive). That's a real reflow, not a compromise.
- Text scales to 200% without loss. 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, so 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 grayscale test: screenshot in grayscale; every sign must still be readable
Automated tools catch ~30% of issues. The manual list is the real gate.