Skip to content

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 / textarea has 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 •••• itself aria-hidden.
  • A status pill's name includes the entity: "Payment pending" not "Pending".

2. Keyboard access

  • Never a div or span as a button without full keyboard support. Use <button>.
  • Everything interactive is reachable by Tab.
  • Focus is always visible.
  • Never tabindex > 0.
  • Escape closes 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>. A div grid with role="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-level on the row header, plus visual indentation — and the depth said in words where it matters ("Total current assets"). Not aria-level: on a plain table's cells and rows it is prohibited ARIA (it belongs to treegrid), and assistive tech ignores it there.
  • Expandable rows: aria-expanded on the toggle, aria-controls pointing 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", not type="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 else polite.
  • Loading uses aria-busy or 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-disabled and, 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:

  • border survives; box-shadow does 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: active to 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-motion respected 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, rem for type, and no overflow: hidden on a text container that could grow.

Testing

Automated (CI, blocking):

  • axe-core on every component story
  • eslint-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.

Told when this spec changes

The docs are the contract the components are built against. When one changes, the component changes with it — and the copy in your repo does not. One email when that happens.

One address, no list rental, unsubscribe in one click.

Works in your QuickBooks Online or Xero file

Get your books closed every month

Lopifi is building AI bookkeeping that keeps your books closed on US GAAP as transactions happen, with a CPA review every quarter, for $200 a month. We’re not open yet.

$200/month when we open. No card now.