Responsive & input
A financial interface carries more content than almost any other product: twelve columns, four periods, nested hierarchy. It still has to work on a phone held one-handed on a train and on a 13" laptop with a trackpad.
The rule behind 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, made per component.
overflow-x: autois the absence of that decision.
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, often at 125% OS scaling with a spreadsheet in the other half of the screen. A dashboard designed at 1920 and "made responsive" 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 handle page layout and input modality. Container queries handle 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 meets this with hit-slop: the visual box is 40px, the touch box 44+.
.target-slop::after {
content: "";
position: absolute;
inset: -4px; /* 40 → 48 */
}
The visual stays dense; the target doesn't. That's how a compact table row keeps 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 and the top ~25% needs 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 |
What we enforce:
- A payment's confirm button is bottom-anchored, above the safe area and always visible. Never at the end 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. A sheet enters from the thumb zone; a centered dialog puts its buttons mid-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.
Use h-dvh, never h-screen. 100vh counts browser chrome that isn't there, so a full-height sheet loses its footer.
Input modality#
A component works in all three modalities. 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 another path: always visible on touch, focus-revealed for keyboard.
Tooltips don't exist on touch. Nobody discovers a long-press tooltip, so information that lives only in a tooltip is unavailable to touch users. Anything essential goes inline. Tooltips carry only supplementary detail (the unabbreviated value, the full timestamp).
Trackpad specifics#
Laptops are the primary target, so trackpad gestures matter:
- Two-finger horizontal scroll must work on a wide table.
overflow-x: autohandles it by default, unless 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 takes away the main tool low-vision users have for reading a dense table.
Pointer events over mouse/touch events#
pointerdown/pointermove/pointerup with setPointerCapture(). One code path covers mouse, touch and pen, and 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, and columns drop lowest-first 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 pattern makes a wide financial table 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 matching up are always on screen.
What 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, to show 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. Use
outline, notbox-shadow.
< 768 — card list. Not a table. A real 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). An overflow menu offers the same actions, 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 can't become a card list, because 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. The label column stays put and the values crossfade (140ms, no translation, since 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 isn't a touch target.
- On touch, the readout is persistent (a fixed header showing the scrubbed point), not a floating tooltip under a finger.
- 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. Never rotate them. 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 behind a toggle, which serves screen readers and anyone who wants the actual numbers.
Forms and the amount input#
inputMode="decimal"on amount fields → the numeric keypad with a decimal separator.inputMode="numeric"has no decimal point, andtype="number"scrolls to increment, which silently changes 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, plusone-time-codeon 2FA inputs so iOS and Android autofill the SMS code.- Never block paste. Repeated here because it keeps happening in card and IBAN fields.
- Validate fields on blur, not on every keystroke. Flagging "not a valid amount" while someone is halfway through typing
12is hostile.
Motion, per modality#
Don't strip motion on mobile. It changes shape instead.
| 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 what makes it feel like an app. Spec in the motion catalog.
Phones are also where motion costs the most. A mid-range Android has a fraction of an M-series MacBook's compositor budget. Use compositor properties only: no backdrop-filter, no animated blur, no large-surface paint. Test on a real mid-range device at 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 isn't offered on phone. 32px rows with 13px text fail both the touch-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.
Print#
Financial documents get printed and saved as PDF all the time. Print is 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. Make sure they render in light mode with enough contrast in grayscale, since most printers are monochrome. That's another reason the grayscale 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 · grayscale |
| 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 required. A simulator can't tell you how a gesture feels or whether a target is big enough, and the 13" MacBook at 125% scaling is where finance teams actually work.