---
title: "Responsive & input"
description: "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."
canonical: "https://lopifi.com/docs/craft/responsive-and-input"
---

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

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

---

# 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: auto` is 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.

```css
.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+.

```css
.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
```css
.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.

```css
@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: auto` handles it by default, unless 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 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 {#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.

```ts
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 `IntersectionObserver` sentinel, to show 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. Use `outline`, not `box-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-subtle` divider 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, and `type="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 (`visualViewport` API, not a guess).
- **`autocomplete` on everything:** `cc-number`, `cc-exp`, `cc-csc`, `name`, `email`, `postal-code`, plus `one-time-code` on 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 `12` is 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](https://lopifi.com/md/docs/motion/catalog#gestures).

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

```css
@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. 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 |
| **Print** | 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.
