Skip to content

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: auto is 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 hijacked wheel events. 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=no is 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 IntersectionObserver sentinel, telling the user there's more. Never a scroll listener.
  • A 1px --border-subtle on the inner edge of each sticky column, so it reads as a pinned pane.
  • Sticky cells must not clip their focus ring — outline, not box-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-subtle divider 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 (visualViewport API, not a guess).
  • autocomplete on everything — cc-number, cc-exp, cc-csc, name, email, postal-code. And one-time-code on 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 12 is 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.


Print

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: avoid on 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
Print 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.

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.