---
title: "Accessibility"
description: "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."
canonical: "https://lopifi.com/docs/craft/accessibility"
---

# 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.

Source: https://lopifi.com/docs/craft/accessibility
Part of: Lopifi — Planned: US GAAP books on autopilot, checked by a CPA (https://lopifi.com)

---

# 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` / `textarea` has 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 `••••` 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 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](https://lopifi.com/md/docs/motion/principles), **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>`. A `div` grid with `role="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:

```html
<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-level` on the row header, plus visual indentation, with the depth said in words where it matters ("Total current assets"). Not `aria-level`: on a plain table's cells and rows it's 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 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](https://lopifi.com/md/docs/catalog/payments#amountinput). For accessibility:

- `inputMode="decimal"` for the right mobile keypad.
- `type="text"`, not `type="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 else `polite`.
- Loading uses `aria-busy` or 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](https://lopifi.com/md/docs/motion/catalog#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](https://lopifi.com/md/docs/foundations/color#contrast-requirements). 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-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 grayscale test is the ship gate.

### Forced colors mode
Windows High Contrast replaces your palette entirely. What that means:

- `border` survives; `box-shadow` doesn'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: active` to restore what's lost:
  ```css
  @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-motion` respected for all non-essential motion. See [motion principles](https://lopifi.com/md/docs/motion/principles#reduced-motion).
- 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](https://lopifi.com/md/docs/craft/responsive-and-input#table-collapse)). That's a real reflow, not a compromise.
- **Text scales to 200% without loss.** 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, 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.
