Responsive & input
The details matter most here. A financial interface has more content than any other product category — twelve columns, four periods, nested hierarchy — and it has to be fully usable on a phone held one-handed on a train and on a 13" laptop with a trackpad.
The rule that governs everything below:
Financial UI doesn't reflow. It re-ranks. Deciding which three of eleven columns matter on a 390px screen is a design decision that must be made per component.
overflow-x: autois not that decision — it's the absence of one.
Breakpoints
xs 0 phone portrait, 320–479 4-col grid, 16px margin
sm 480 phone landscape, small tablet 4-col
md 768 tablet portrait 8-col, 24px margin
lg 1024 LAPTOP — the primary target 12-col
xl 1280 desktop 12-col
2xl 1536 large desktop 12-col, max 1440 centered
lg (1024–1440) is the design target, not 2xl. Finance teams work on 13–14" laptops, frequently at 125% OS scaling and often with a spreadsheet in the other half of the screen. A dashboard designed at 1920 and "made responsive" is a dashboard that has never been used at its real width.
Design at 1280 and 390 first. Everything else is interpolation.
Container queries over media queries
A card doesn't know the viewport; it knows its own width. Use container queries so the same BalanceCard works in a 12-col hero and a 3-col sidebar without a variant prop.
.card { container-type: inline-size; container-name: card; }
@container card (max-width: 320px) { .figure { font-size: 20px; line-height: 28px; } }
Media queries stay for page layout and input modality. Container queries for component internals. That split is the whole responsive architecture.
Touch targets
Minimum 44 × 44 CSS px (WCAG 2.5.5 AAA / Apple HIG)
Comfortable 48 × 48
Spacing ≥ 8px between adjacent targets
Our default 40px control satisfies this via hit-slop — the visual box is 40px, the touch box is 44+:
.target-slop::after {
content: "";
position: absolute;
inset: -4px; /* 40 → 48 */
}
Visual density stays tight; the target doesn't. This is the technique that lets a compact table row still have tappable actions.
Never shrink a target to fit. If three actions don't fit at 44px, show one and put the rest in an overflow menu.
Thumb zones (phone, one-handed)
On a 390 × 844 screen held in one hand, the bottom ~40% is comfortable, the top ~25% requires a grip shift.
| Zone | Put here |
|---|---|
| Bottom 0–35% | Primary actions, tab bar, sheet confirm buttons, keypad |
| Middle 35–70% | Content, the values being read |
| Top 70–100% | Title, back, secondary/destructive actions, filters |
Consequences that are actually enforced:
- A payment's confirm button is bottom-anchored, above the safe area, always visible — never at the bottom of a scrolling form where it can be missed.
- Destructive actions live at the top, deliberately out of thumb reach.
- Sheets beat dialogs on phones, always. A sheet enters from the thumb zone; a centered dialog puts its buttons in the middle of the screen.
Safe areas
.bottom-bar { padding-bottom: max(16px, env(safe-area-inset-bottom)); }
.sheet-footer { padding-bottom: max(24px, env(safe-area-inset-bottom)); }
.top-bar { padding-top: env(safe-area-inset-top); }
.full-width { padding-inline: max(16px, env(safe-area-inset-left)); }
Required on every fixed element. The last one matters in landscape on a notched device, where content otherwise slides under the notch.
And h-dvh, never h-screen — 100vh includes browser chrome that isn't there, so a full-height sheet gets its footer cut off.
Input modality
Three modalities, and a component must work in all three. Detect capability, never device.
@media (hover: hover) and (pointer: fine) { /* mouse / trackpad */ }
@media (hover: none) and (pointer: coarse) { /* touch */ }
@media (hover: hover) and (pointer: coarse) { /* TV, some tablets w/ pen */ }
| Mouse / trackpad | Touch | Keyboard | |
|---|---|---|---|
| Reveal row actions | hover | always visible or swipe | focus |
| Tooltip | hover, 400ms delay | long-press, or none | focus |
| Sort | click header | tap header | Enter on header |
| Multi-select | Shift+click, Cmd+click |
checkboxes, always visible | Shift+arrows |
| Reorder | drag | long-press then drag | Cmd+arrows |
| Scrub a chart | hover | drag with a persistent readout | arrows |
| Dismiss | click outside / Esc |
swipe down / tap outside | Esc |
The hover trap: touch devices fire :hover on tap and it sticks until the next tap elsewhere. Every hover effect is gated behind @media (hover: hover). Every hover-revealed affordance has a non-hover path — always visible on touch, or focus-revealed for keyboard.
Tooltips do not exist on touch. A long-press tooltip is undiscoverable. If information is only in a tooltip, it's unavailable to touch users — so anything essential goes inline, and tooltips carry only supplementary detail (the unabbreviated value, the full timestamp).
Trackpad specifics
The laptop primary target means trackpad gestures matter:
- Two-finger horizontal scroll must work on a wide table. It does by default with
overflow-x: auto— but not if you've hijackedwheelevents. Don't. - Overscroll containment on modals and sheets:
overscroll-behavior: contain, so scrolling to the end of a sheet doesn't start scrolling the page behind it. - Never hijack pinch-zoom.
user-scalable=nois forbidden — it's an accessibility failure and it breaks the primary tool low-vision users have for reading a dense table.
Pointer events over mouse/touch events
pointerdown/pointermove/pointerup with setPointerCapture(). One code path for mouse, touch, and pen; capture keeps a drag alive when the pointer leaves the element.
Table collapse
The most important responsive decision in the library, because the table is the product.
Column priority
Every column declares a priority. Columns drop from lowest priority as width shrinks — the design decision is the ranking, made once per table, not per breakpoint.
type ColumnDef = {
id: string;
priority: 1 | 2 | 3 | 4; // 1 = never dropped
minWidth: number;
sticky?: "start" | "end";
};
Transaction table ranking:
P1 date · description · amount ← never dropped, never truncated (amount)
P2 status · account
P3 category · merchant
P4 reference · memo · created-by · tags
The four presentations
≥ 1280 — full table. All columns. Sticky header. Sticky total footer.
1024–1280 — priority table. P4 dropped into a per-row expand. Description column flexes and truncates.
768–1024 — sticky-column table. Description is position: sticky; left: 0, amount is sticky; right: 0. The middle scrolls horizontally between them.
This is the pattern that makes a wide financial table genuinely usable at laptop width, and almost nothing implements it: the label and the number stay pinned; the metadata scrolls between them. The user never loses their place because the two things they're correlating are always both on screen.
Craft details it needs:
background: var(--surface)on the sticky cells, or scrolled content shows through.- Edge fades — a 24px gradient at each scroll boundary, driven by an
IntersectionObserversentinel, telling the user there's more. Never a scroll listener. - A 1px
--border-subtleon the inner edge of each sticky column, so it reads as a pinned pane. - Sticky cells must not clip their focus ring —
outline, notbox-shadow.
< 768 — card list. Not a table. A genuine re-rank:
┌────────────────────────────────────┐
│ ◻ Northwind Ltd −$1,240.00│ description · amount (P1)
│ 3 Sep · Operating ••4291 │ date · account (P1/P2)
│ ● Pending │ status (P2)
└────────────────────────────────────┘
- 72px row height, 16px inset, full-bleed
--border-subtledivider between rows. - Amount right-aligned,
tabular-nums, never truncated. - Logo/avatar at 32px, rounded-square 8.
- Swipe-left reveals actions (categorize, flag) — with an overflow menu as the discoverable equivalent, because swipe is invisible.
- Tap opens a detail sheet with everything, including P3/P4.
- Sticky section headers by date, 32px tall,
--surface-sunken. - Virtualized past 100 rows.
Statements on a phone
A P&L has 3–13 period columns. It cannot become a card list — the comparison across periods is the content.
- One period column at a time, with a horizontally swipeable period selector at the top. The account label column stays fixed.
- The selector is a segmented control that scrolls, with the active period pinned in view.
- Swipe left/right on the statement body moves between periods, with the label column static and the values crossfading (140ms, no translation — translation implies the labels moved too).
- Indentation halves on phone (16px per level instead of 24) so a level-4 account name isn't 96px into a 358px content width.
- Rule lines and the double-rule total survive at every width. They're the grammar.
Charts
- Minimum touch scrub target 44px tall across the full plot area — a 2px line is not a touch target.
- On touch, the readout is persistent (a fixed header showing the scrubbed point), not a floating tooltip that a finger covers.
- Reduce data density, not chart size. A 30-point line on a 358px canvas is legible; a 365-point line is a smudge. Downsample on small screens with an LTTB algorithm, and say so.
- Axis labels: abbreviate on phone (
$1.2M), full on desktop. Rotate never — if labels don't fit, show fewer. - Legends move below the plot on phone and become a wrapping row.
- A chart is never the only representation. Every chart has an accessible table equivalent, reachable by a toggle — which serves screen readers and the user who wants the actual numbers.
Forms and the amount input
inputMode="decimal"on amount fields → the numeric keypad with a decimal separator.inputMode="numeric"gives no decimal point;type="number"gives scroll-to-increment, which will silently change someone's payment.- 16px minimum font-size on inputs. iOS Safari zooms the viewport on focus for anything smaller, and the zoom doesn't reverse. This overrides our 14px default — inputs are
body-lg. - Bottom-anchored submit on phone, above the safe area, visible without scrolling.
- The keyboard covers the bottom 40% of the screen. Scroll the focused field into view above it, and re-anchor the submit button above the keyboard (
visualViewportAPI, not a guess). autocompleteon everything —cc-number,cc-exp,cc-csc,name,email,postal-code. Andone-time-codeon 2FA inputs, so iOS/Android autofill the SMS code.- Never block paste. Restated because it keeps happening in card and IBAN fields.
- Field-level validation on blur, not on every keystroke. Validating "not a valid amount" while someone is halfway through typing
12is hostile.
Motion, per modality
Motion is not decoration to strip on mobile — but it changes shape.
| Phone | Laptop / desktop | |
|---|---|---|
| Primary overlay | Sheet, from the bottom, gesture-dismissible | Dialog, centered, Esc |
| Dismissal | Swipe (velocity-aware) | Click-outside / Esc, instant |
| Row actions | Swipe-reveal, spring-tracked | Hover-reveal, instant |
| Navigation | Instant. No page transitions. | Instant. |
| Hover effects | None (gated out) | Yes, 140ms |
| Card tilt | None — no pointer to track | Yes, spring-tracked |
| Stagger | Yes on first paint | Yes on first paint |
| Value flash | Yes | Yes |
Phones are where motion earns its keep, because gestures are the navigation. A sheet that tracks the finger 1:1 and springs back on release is the difference between an app and a website. Spec in motion catalog.
And phones are where motion costs the most. A mid-range Android has a fraction of the compositor budget of an M-series MacBook. So: compositor properties only, no backdrop-filter, no animated blur, no large-surface paint. Test on a real mid-range device throttled to 4× CPU slowdown — not on the laptop that wrote the code.
Density × breakpoint
Density and breakpoint are independent axes. The defaults:
| Phone | Tablet | Laptop | Desktop | |
|---|---|---|---|---|
| Default density | comfortable | default | default | default |
| Available | comfortable only | default, comfortable | all three | all three |
| Row height | 72 (card) | 48 | 40 | 40 |
| Card inset | 16 | 24 | 24 | 24 |
| Body size | 14/20 (16 in inputs) | 14/20 | 14/20 | 14/20 |
compact is not offered on phone — 32px rows with 13px text on a touch device fails both the target minimum and legibility at arm's length.
Density is persisted per user and syncs across devices, but the phone always resolves to comfortable regardless of the stored preference.
Financial documents get printed and PDF'd. Constantly. Ignoring print is ignoring a primary workflow.
@media print {
:root { color-scheme: light; } /* never print dark mode */
.no-print { display: none; } /* nav, filters, actions, toolbars */
.card { box-shadow: none; border: 1px solid #ccc; break-inside: avoid; }
thead { display: table-header-group; } /* repeat headers on every page */
tr { break-inside: avoid; }
.statement-total { break-before: avoid; } /* never orphan a total from its rule */
a[href^="http"]::after { content: " (" attr(href) ")"; }
@page { margin: 16mm; }
}
- Every statement prints with its header: entity name, statement type, resolved period, basis (cash/accrual), presentation currency, and generation timestamp. A printed P&L with no period is worthless.
- Page numbers and "continued" on multi-page statements.
- Never break a total from its rule line.
break-before: avoidon total rows. - Charts print — ensure they render in light mode with sufficient contrast in greyscale, since most printers are monochrome. Another reason the greyscale test is a ship gate.
Testing matrix
Every component, before stable:
| Widths | 320 · 390 · 768 · 1024 · 1280 · 1920 |
| Zoom | 100% · 200% · 400% (= 320px reflow) |
| Modality | mouse · touch · keyboard-only · screen reader |
| Density | compact · default · comfortable |
| Theme | light · dark · forced-colors · signal palette · greyscale |
| Motion | normal · reduced |
| Devices | real iPhone SE (smallest current) · real mid-range Android · 13" MacBook at 125% scaling |
| Throttle | 4× CPU slowdown, Fast 3G |
| statement PDF, multi-page |
The real-device rows are not optional. Gesture feel and touch-target adequacy do not survive a simulator, and the 13" MacBook at 125% scaling is where finance teams actually live.