Grid & layout
Every component in Lopifi is designed in four layers, in order. You do not skip ahead. Layer 4 (the actual type) is the last decision, not the first — which is why the system stays coherent when a new component is added by someone who wasn't in the original room.
The four-layer method
Layer 1 ▸ BASE GRID 8px lattice. Nothing else exists yet.
Layer 2 ▸ SAFE SPACE Padding and grouping. Where content may live, and what belongs with what.
Layer 3 ▸ STRUCTURE Text slots, media placeholders, controls. Grey boxes only.
Layer 4 ▸ TYPE Actual sizes and weights. Max 3 of each.
Each layer is a separate artboard in the design file and a separate commit in the build. A component review can reject at any layer without touching the ones after it.
Layer 1 — the base grid
8px base unit. Set as tightly as possible to preserve flexibility later — a coarse grid (12, 16) makes small controls impossible; a fine grid (2, 4) stops being a grid at all. 8 is the tightest unit that still constrains.
--space-0: 0px
--space-0-5: 4px /* half-step — restricted, see below */
--space-1: 8px
--space-2: 16px
--space-3: 24px
--space-4: 32px
--space-5: 40px
--space-6: 48px
--space-8: 64px
--space-10: 80px
--space-12: 96px
--space-16: 128px
The half-step rule
4px exists but is restricted to the interior of controls ≤ 32px tall — badge padding, pill insets, icon-to-label gaps inside a small button, checkbox-to-label. It may never appear in layout: no 4px gaps between cards, no 12px card padding, no 20px section spacing.
Rationale: a 32px control cannot be padded on an 8px lattice without becoming visually wrong (8px vertical padding + 16px line-height = 32px works; but a 24px control needs 4px). Everything above control scale stays on 8.
Forbidden values: 6, 10, 12, 14, 18, 20, 28, 36, 44. If a design wants 12px, the answer is 8 or 16, and the question is which grouping was wrong.
Heights are on the grid too
Control heights 24 (xs) · 32 (sm) · 40 (md, default) · 48 (lg)
Table row heights 32 (compact) · 40 (default) · 48 (comfortable)
Header / toolbar 56
App top bar 64
Mobile tab bar 56 + safe-area-inset-bottom
md (40px) is the default control height because it is the smallest height that comfortably clears a 44px touch target once its 4px of ambient hit-slop is counted — see responsive & input.
Layout grid
- Desktop / laptop: 12 columns, 24px gutter, 24px page margin, max content width 1440. Below 1440 the grid is fluid; above it, centered with the canvas continuing.
- Tablet: 8 columns, 24px gutter, 24px margin.
- Phone: 4 columns, 16px gutter, 16px margin. Note the margin drops to 16 — this is the one place the page margin leaves 24, because 24px margins on a 390px screen cost 12% of the viewport.
Layer 2 — safe space and grouping
Safe space is the padding and the proof that things belong together. Two elements 8px apart are one thing; 24px apart they are two things. That is the entire hierarchy mechanism at this layer.
The inset scale
Card / panel inset 24px ← the system default
Compact card inset 16px (dense mode, or cards < 240px wide)
Dialog inset 24px
Sheet inset 24px (16px on phone)
Table cell inset 12px v / 16px h — the one sanctioned 12, see note
Toolbar inset 16px
Popover / menu inset 8px (items get 8v/12h)
Table cell vertical padding is 12px. This is the sole layout exception to the half-step rule and it is deliberate: a 40px row with 16px line-height needs 12px above and below. Documented as an exception rather than silently violated. Everything else obeys.
The proximity ladder
Distance encodes relationship. Use only these five:
| Gap | Meaning | Example |
|---|---|---|
| 4px | Same object | Currency symbol → figure; icon → its label |
| 8px | Same group | Label → value; two stacked metadata lines |
| 16px | Related, separate | Two fields in a form; card header → card body |
| 24px | Distinct groups | Two cards in a grid; form section → form section |
| 32–48px | Page sections | Statement section → next section; hero → content |
If you need a sixth, one of your groupings is wrong.
Safe space is not negotiable at the edge
The 24px card inset is a hard boundary. Nothing bleeds into it except three sanctioned cases:
- Full-bleed dividers — a
1pxrule that spans the full card width, used between rows in a list card. Achieved with negative margin, not reduced padding. - Full-bleed media — a chart or image that touches three edges (see media rules below).
- Hover/selection backgrounds on list rows, which extend to the card edge minus the radius clearance.
Everything else respects 24.
Layer 3 — structure
Grey boxes. No type, no color, no icons. You are deciding how many things there are and where they sit — and this is where most components are silently over-designed, because it's very hard to justify a fourth text slot when it's an unlabeled grey rectangle.
Every Lopifi component's structure spec names its slots explicitly:
BalanceCard
├── eyebrow ▸ optional · 1 line · truncate
├── figure ▸ required · 1 line · never truncates, shrinks instead
├── delta ▸ optional · 1 line
├── media ▸ optional · sparkline, full-bleed bottom
└── actions ▸ optional · max 2, one primary
Structural rules
- Max 3 text slots per component. A fourth is a sub-component.
- Every slot declares its overflow behavior —
truncate,line-clamp-2,shrink, ornever overflows(with the constraint that guarantees it). - Media placeholders declare aspect ratio, never fixed height.
aspect-[16/9],aspect-[1.586]for cards,aspect-[3/1]for sparkline strips. - Controls declare their count ceiling. "Max 2 actions, at most 1 primary." Enforce it in the type signature where you can.
- The figure never truncates. In a finance component, a truncated number is a bug. It shrinks (via a size step-down) or the container grows. See numerics.
Layer 4 — type
Covered fully in typography. The rule that lives here:
Max 3 font sizes and 3 font weights per component.
Not per page — per component. This is the constraint that generates hierarchy, because with only three sizes available you are forced to spend them on the three things that actually matter.
A component that genuinely needs four sizes is two components that should be composed.
Optical alignment
The grid gets you 95% there. These fix the rest, and they are the difference between "aligned" and "looks aligned".
Icons align to cap height, not line box
A 16px icon next to 14px/20px text sits 2px above the text's optical center if you align boxes. Align the icon's optical center to the text's cap height center:
.icon-inline { transform: translateY(-0.5px); } /* 16px icon, 14px/20 Inter */
Measure per size pair; document the offset. Lopifi's values:
| Icon | Text | Offset |
|---|---|---|
| 16px | 13px/16 | -0.5px |
| 16px | 14px/20 | -0.5px |
| 20px | 16px/24 | -1px |
| 24px | 20px/28 | -1px |
Right-aligned numbers hug the inset
The last column of any numeric table aligns its right edge to the card inset, not to a cell padding inside it. A number that stops 16px short of the card edge while the header above it touches the edge is the most common table-craft failure.
Currency symbols are optically offset
$ is taller than lining figures and sits differently from € and £. When the symbol is set at a lighter weight/color (which it always is — see typography), give it a 4px gap and let it baseline-align; do not attempt vertical centering.
Trailing punctuation and parentheses hang
In a right-aligned column containing both 1,234.00 and (1,234.00), the closing paren pushes the digits left by ~4px and destroys decimal alignment. Fix: reserve the paren column always — render a transparent ) for positive values, or use a fixed-width paren cell. Detail in numerics.
Optical centering in circles
An icon inside a circular avatar or a play/chevron glyph needs +0.5px to +1px toward the direction it points. Chevrons in > accordions get translateX(0.5px).
Density modes
Three modes, set by a data-density attribute on any container, cascading to descendants.
compact |
default |
comfortable |
|
|---|---|---|---|
| Row height | 32 | 40 | 48 |
| Card inset | 16 | 24 | 24 |
| Body size | 13/16 | 14/20 | 14/20 |
| Cell h-padding | 12 | 16 | 16 |
| Gap ladder | ×0.75 (rounded to 4) | ×1 | ×1.25 |
compact is for power users on large displays reviewing hundreds of rows. comfortable is the phone default. Density is not a visual preference — it's an information-throughput setting, and it must be persisted per user and respected by every component that renders a list.
Layout composition rules
- Cards in a grid are equal-height by default. Use
gridwithauto-rows-fr, not flex-with-stretch, so the figure baselines align across the row. - Never nest more than two card levels. A card inside a card inside a card is a hierarchy failure; the inner one should be a section with a rule line.
- Sticky elements are on the grid too. A sticky table header is exactly one row height. A sticky total footer is exactly one row height + 1px top rule.
h-dvh, neverh-screen. Mobile browser chrome makes100vhwrong.- Fixed elements respect
env(safe-area-inset-*). Bottom bars, floating action buttons, sticky totals. - Scroll containers own their own padding. Padding on a scroll parent clips the shadow of scrolled content; put it on the inner track.