Spacing Architecture for Dense Desktop-Class Web Apps
Why prose-site spacing guidance runs out of road in chrome-dense app UI — role tokens over raw scale steps, a two-ladder pairing rule, the display-scale golden rule and its exceptions, and a ratchet-lint migration pattern.
The Problem
The spacing guidance elsewhere on this site — a tight semantic scale, hsp/vsp axes, generous rhythm — was written from prose-site experience: documents with one column, sections, headings, and paragraphs, where the largest tokens reach 100–250px. A desktop-class app is a different animal. An editor, a mail client, or an IDE-like tool is mostly chrome: toolbars, dialogs, context menus, pickers, trees, and board views — dozens of 25–50px-tall rows sharing one viewport, often with a user-controlled display scale multiplying every dimension (see Display Scale Strategy).
Rearchitecting the spacing of one such app end to end — roughly 3,000 spacing-token usages, measured with getComputedStyle before and after — produced two findings that prose-site guidance does not predict:
The visible bugs were never invalid values. Every broken surface used legal tokens. The failures were relationships: a timeline whose between-card gap (~25px) matched its within-card line rhythm (~24px) so the cards fused into one list; five different row densities (25, 29, 32, 49, and 56px tall) for the single role "pick one row from a vertical list."
Most token-level inconsistencies were invisible. Four different dialog horizontal insets (24/20/16/12px) coexisted for months and no one ever saw it — only one dialog is on screen at a time. Toolbar padding that differed by 2px, badges that differed by 1px: measurably imperceptible.
Together these invert the usual cleanup budget. Chasing every token inconsistency is mostly churn; the payoff lives in a handful of ratio, grouping, and role failures that a token audit cannot even represent.
About the evidence in this article
The measurements, counts, and ratios throughout come from one app — a Tauri v2 desktop tool, measured before and after with getComputedStyle. They are real numbers, not illustrations, but they are a single project's numbers: treat them as a worked example that shows the shape of the problem, not as thresholds to copy. The perceptual verdicts in particular — "fused", "imperceptible", "reads as one group" — are visual-review judgments by the team, not user-study findings. Where this article states a rule, the rule is a house heuristic that survived contact with 3,000 usages; validate it against your own rendered pixels before adopting it.
The Solution
An app spacing architecture needs different machinery than "use the scale". Five pieces, each a section below:
| Piece | What it decides |
|---|---|
| Rendered-pixel diagnosis | what is actually broken, and what to leave alone |
| The two-ladder pairing rule | which two gaps may meet at a grouping boundary |
| Role tokens | what a recurring surface is called, so it stops drifting |
| Common region | how to group when the within-group rhythm is not yours to set |
| The display-scale contract | which dimensions multiply with zoom, and which must not |
A migration-grade lint (the ratchet) then holds the result in place. The order matters: diagnose first, because the diagnosis is what tells you which of the rest you actually need.
Diagnose Rendered Pixels Before Architecting
Grep counts lie about visual impact in both directions. Before deciding anything, measure the app the way its users see it: screenshot every surface in a fixed matrix (empty / populated / overlay-open / long-text / focus states, desktop and mobile widths, multiple display scales), and read the actual gaps with getComputedStyle / getBoundingClientRect. Then rank problems by how badly the grouping misreads on screen — not by how many files carry the inconsistency.
In the motivating project the measured ranking came out:
| Rank | Surface | Measured | Verdict |
|---|---|---|---|
| 1 | Timeline cards | between-card ~25px vs within-card rhythm ~24px (ratio ≈ 1.0×) | cards fuse into one list |
| 2 | Menu rows | five densities for one role: h25/h29/h32/h49/h56 | density soup |
| 3 | Command palette | py-12/h49 vs dropdown py-4/h29 | 2× outlier row |
| 4 | Dividers | ~20px above / 12px below the line | ambiguous separator role |
| — | Dialog insets 24/20/16/12 | one dialog visible at a time | invisible — deprioritize |
| — | 2px toolbar / 1px badge deltas | below perception | invisible — deprioritize |
Two surfaces measured as already correct became the north-stars for every later decision: a kanban card list (card↔card 6px vs card↔add-affordance 26px = 4.3×) and a settings form (label↔control 10px vs group↔group 28px = 2.8×). When you find validated ratios in your own app, write them down — "a clear grouping boundary lands around 2.5×–4.5× here, never below 2×" is a far more concrete instruction than "space it nicely," and it is drawn from your own surfaces rather than a rule of thumb.
The governing rule that falls out: spend complexity only where the pixels are visibly broken. Where the diagnosis proves a defect invisible, unify to one value for maintainability and stop — no role token, no redesign, no debate.
The Two-Ladder Ratio Doctrine
Spacing Philosophy establishes the proximity rule: between-group gaps must clearly exceed within-group gaps, roughly 2×. The hard part in an app is operationalizing that rule over a dense scale. App chrome needs closely spaced steps (2, 4, 6, 8, 12px…) for fine control — and adjacent steps on a dense scale are 1.2–1.5× apart, exactly the illegible ratio band. Deleting steps to force wider ratios is pure churn when thousands of legitimate within-group usages depend on them.
The fix is a construction trick, not a smaller scale. A dense scale built on doublings usually contains two interleaved ×2 ladders:
Ladder A (×2): 4 → 8 → 16 → 32 (2 extends it downward)
Ladder B (×2): 6 → 12 → 24 → 48
Off-ladder: 20 (a fitting value between 16 and 24)The heuristic: at any grouping boundary, pick the within-group gap and the between-group gap from the same ladder, at least one rung apart. One rung is exactly 2× — the proximity ratio becomes automatic, and the rule is memorable enough to apply in review ("same ladder, one rung up"). It applies where whitespace is the primary grouping cue; where borders, fills, or bounded regions carry the grouping, they set the reading and the gap ratio matters less (see the common-region section below).
| Within-group | Valid between-group (same ladder, ≥1 rung up) | Ratio |
|---|---|---|
| 4 (A) | 8 / 16 / 32 | 2× / 4× / 8× |
| 6 (B) | 12 / 24 / 48 | 2× / 4× / 8× |
| 8 (A) | 16 / 32 | 2× / 4× |
| 12 (B) | 24 / 48 | 2× / 4× |
The pairings to avoid are equally teachable: any two adjacent steps used as within/between (4↔6, 6↔8, 8↔12, 12↔16, 16↔20…) tend to read as one group, because the ratio lands in the 1.2–1.5× band the eye does not resolve as a boundary. The off-ladder step (20) earns its place in absolute fitting — an inset that must clear a specific control — and makes a poor grouping partner because it has no same-ladder rung to pair with. If you catch yourself pairing 16-within with 20-between, 32 is the ladder's answer.
Both columns use 16px within each group. The left pairs it with 20px between groups — a legal off-ladder step, but only 1.25× the within-gap, so all four rows read as one list. The right pairs the same 16px with 32px, one rung up the same ladder: two groups, unmistakably. Squint at both and the difference is immediate.
Two things to note about enforcement. First, a value-checking linter cannot see ratio — it checks values in isolation, and every value in a broken pairing is legal. The doctrine is applied by review and by the squint test. Second, the doctrine is why the base scale never had to change: the migration left the token vocabulary and all ~3,000 usages intact, and fixed every boundary whose gaps were authored by re-pairing them alone. That is what made the project tractable. It is not the whole story — where the within-group rhythm was intrinsic rather than authored, re-pairing could not help and a different mechanism was needed, which the next section covers.
Role Tokens Over Raw Scale Steps
The menu-row soup (five densities for one role) is not fixed by picking better scale steps — any steps chosen per-file will drift again. It is fixed by naming the role and making every occupant of the role consume the name.
The demo illustrates the shape of the drift rather than reproducing the measured heights exactly. The left column is what unmanaged drift produces: every row is a legal token pairing, and the list still reads as five unrelated things. The right column collapses the same role onto two named tiers — and the deliberate step between them reads as a density choice, not a grouping boundary. Two delivery mechanisms cover all cases:
Shared class constants — when the role is a pairing of existing base-scale utilities. A row is a px+py pair; a dialog header is px+py+border. A single Tailwind spacing token names one value, so it cannot bundle the several utilities a role needs; a named, exported constant can:
// exported from the shared UI package
export const menuItemCls = "px-md py-xs max-mobile:min-h-touch-min"; // compact menu row
export const pickerRowCls = "px-lg py-sm min-h-touch-min"; // search-overlay row
export const dialogHeaderCls =
"flex items-center justify-between px-3xl py-xl border-b border-edge";No new token, lint stays trivial (the underlying classes are already valid tokens), and grep menuItemCls makes adoption auditable — it shows every site that opted into the role, though it cannot prove no row still hand-rolls its own padding. In the motivating app, collapsing five row densities onto two named tiers (compact menu 8/4, picker row 12/6 with a 44px touch floor) took the app from five row paddings to exactly two, verified by computed style.
Component spacing tokens — when the role's value is off the base scale or needs independent scaling behavior:
--spacing-close-btn: 28px; /* the dialog close-X hit box */
--spacing-section-gap: 28px; /* between-group boundary in dialog/settings bodies */
--spacing-card-gap: 16px; /* between bordered cards on board surfaces */
--spacing-touch-min: 44px; /* iOS-HIG touch floor — scales with a floor, see below */The bar for creating one is the same architectural judgment as in Component Tokens & Arbitrary Values: the value must name a design decision, not a value that merely recurs. Two hard-won corollaries:
Fewer tiers than you think. The temptation was three row tiers (menu / listbox / palette). Three tiers is one decision too many per call site — the ambiguity between "menu" and "listbox" is exactly what produced the soup. Two survived.
Coincidental equality must not couple. The app had a 28px settings hint-indent and an unrelated 28px prose steps-indent. Same number, different roles — sharing one token would weld two decisions together so neither can move. Equal values earn separate names (or one stays a documented arbitrary) when their reasons differ.
Common Region Beats Gap Ratio When the Rhythm Is Intrinsic
The proximity rule has a boundary condition that dense app surfaces hit constantly — the general version, and the same timeline example, is in Spacing Philosophy; this section is the app-scale reading of it. The timeline's within-card "gap" of ~24px was not a gap — it was the intrinsic line-height of stacked label/title/body text. You cannot shrink it without harming readability, and out-ratioing it would demand ~48px between cards, wasting half the viewport on air.
When the within-group rhythm is intrinsic, stop fighting with spacing and switch Gestalt principles: from proximity to common region — the principle that elements inside one bounded area read as a unit regardless of their internal spacing. Give each card a bounded box — full border, radius, and a fill — and a modest gap between boxes. (The fill also adds a figure/ground cue, distinguishing card from background; common region is the load-bearing principle, and the fill reinforces it where the theme provides contrast.)
The within-card text rhythm is identical in both columns — untouched. Only the boundary treatment changed. In the motivating app this took the timeline from "eight cards fuse into one scrolling list" to discrete rounded boxes with a measured 16px gap, confirmed by re-measurement at four display scales.
One theme-dependent subtlety: in a dark theme the card fill may compute equal to the board background (surface == bg), which removes the figure/ground cue entirely — there is no card-vs-background contrast left. Common region still holds, because border + radius + gap bound the region on their own. That is fine — the app's validated kanban reference had exactly that computed state — but it means a "card background must differ from container" verification predicate is theme-limited and tests the wrong thing. Apply the fill class anyway (themes where the tokens diverge get the figure/ground cue for free) and let border + gap + radius carry the guarantee.
The Display-Scale Golden Rule and Its Exceptions
In a display-scaled app (Display Scale Strategy) the spacing system has one golden rule: every dimension the user perceives as UI is calc(Npx * var(--display-scale)) — and a short, principled list of exceptions where scaling is wrong:
| Exception | Treatment | Why |
|---|---|---|
| Hairlines (1px borders, dividers, tree connectors) | fixed 1px, never multiplied | must stay crisp at every scale; only their position scales |
| Touch-target floor | max(44px, calc(44px * var(--display-scale))) | the floor is a product decision, not a scaled value: at 0.75× a scaled 44 hits 33px. WCAG 2.2 sets 24×24 CSS px as the AA minimum and 44×44 as the AAA enhanced target — pick your floor deliberately, then hold it at ≤1.0× and grow above |
| Sub-4px bars (accent stripes, drag pills, insertion indicators) | max(2px, round(3px * var(--display-scale), 1px)) | raw 3px×0.75 = 2.25px is a fractional value the browser must antialias; 3px×1.5 = 4.5px lands on a half-pixel. Floor + integer rounding keeps the value whole (measured: 2/3/5/6 at 0.75/1.0/1.5/2.0). Integer CSS px is not a crispness guarantee — device-pixel alignment still depends on DPR and position — but it removes the fractional-size source. Keep an unrounded fallback declaration for WebViews without round() |
| Overlay vertical anchors | plain vh, not scaled | viewport-relative by design |
| Prose content | em-based, out of the token system entirely | rides the font size, which scales on its own axis |
| Platform-quirk px (16px iOS zoom threshold, safe-area math) | fixed, documented in-file | imposed by the platform, not the design |
Step through the scales. The tokenized square grows 21/28/42/56 while the red w-[28px] arbitrary sits at 28px forever — invisible at 1.0×, which is exactly why it survives review. The accent bar floors at 2px instead of smearing at 2.25px, the touch target refuses to drop below 44px at 0.75×, and the hairline stays 1px at every step.
Derived values must derive: a bar's centering offset is calc(var(--spacing-accent-bar) / -2), never a hardcoded -1.5px. And where geometry is computed in JS (per-depth tree indents), round once per computed value — Math.round((base + step * depth) * scale) — so every row and its guide line land on the same integer device pixel at fractional scales. The one bug class this catches: a component that was never wired to the multiplier. In the motivating app, one tree implementation out of five (a settings sidebar) simply didn't scale — frozen at 1.0× geometry at every zoom level — and nobody had noticed because it drifted only when zoom changed. Unifying all five trees onto one treeIndentStyle(depth) helper fixed it as a side effect, verified at 0.75/1.0/1.5 (indent 9/12/18).
Tailwind v4 wrinkle: in the two-block arrangement this site documents in Display Scale Strategy, each scaled token is declared twice — the unscaled base in @theme, which gives Tailwind the value it needs to generate the utility class, and the calc() override in :root, which wins the cascade at runtime. (This is a property of that arrangement, not a hard limitation of @theme — Tailwind v4 does allow variable references in theme values, and @theme inline changes how they are emitted. Check your toolchain before assuming the split is mandatory.) Where the split is in use it is load-bearing, and it is also a footgun: add a token to one block and not the other and you get either a dead utility or an unscaled value. Guard it with a unit test asserting the two blocks' --spacing-* key sets are equal minus a documented fixed-token allowlist.
Recurring App Patterns Worth Naming
These roles recurred across every surface of the audited app and are likely to exist in yours. Naming them up front short-circuits a lot of drift:
| Pattern | Shape | Notes |
|---|---|---|
close-btn | one square hit-box size for every dialog close-X | the single most copy-pasted literal in the app (dozens of w-[28px] h-[28px] sites) — tokenize before it spreads |
touch-min | 44px floored minimum for every interactive row/button | the floor is the point — see exceptions table |
card-gap | gap between bounded cards on board-like surfaces | pairs with the common-region recipe |
section-gap | the between-group boundary inside dialog/settings bodies | names your validated within↔between north-star (here: 10↔28 ≈ 2.8×) |
tree-indent family | base + step × depth, plus a fixed 1px connector | one helper for every tree; JS-rounded per depth |
hint-indent | indent aligning help text under a checkbox label | often an optical-fudge composite (checkbox 16 + gap 8 + 4px) — document the composition in a comment |
accent-bar | 3px priority stripe / drag indicator | floored + rounded, consumed as a border-width or w/h |
icon-btn-mini | second-tier small icon button below the standard square | prevents 22/24px near-duplicates |
overlay-offset-top | one vh anchor for the whole type-ahead overlay family | unify before each overlay picks its own 5/10/20vh |
None of these is on the base scale, and that is the point — they are design decisions with names, not scale steps (Two-Tier Size Strategy makes the same argument for element sizing).
The Ratchet-Lint Migration Pattern
Turning strict spacing lint on in a mature codebase fails in both naive forms: "fix everything first" never lands, and "warn-only" is ignored forever. The pattern that carried this migration — six parallel rollout areas, zero merge conflicts — is a per-area ratchet: land the rule with a full violation baseline green on day one, give each rollout area its own allowlist file so parallel branches never collide, auto-accept the sanctioned calc(Npx*var(--display-scale)) golden path by pattern rather than per-entry, then consolidate the survivors and lock the rule to a hard error. Design Token Lint has the full mechanics — the baseline keying, the whole-expression validation, and the digit-prefixed-token regex trap; this section covers only what is specific to an app-scale spacing migration.
Measured here: 31 baseline allowlist entries across five area files → 2 permanent survivors (both mx-[0.25em], em-based inline nudges that are exempt by principle), final lint clean over 1,042 files. The reason the base scale could stay frozen through all of it is the two-ladder doctrine above: the migration re-paired boundaries, it did not renumber the scale, so the ratchet only ever had to shrink allowlists — never rewrite thousands of call sites.
The one app-specific caveat is what the lint still cannot gate. A green design-token lint proves values, not layouts, so two defect classes ship straight through it: ratio/grouping failures (every value legal, the relationship broken) and fixed-px sizing arbitraries that opt out of the display scale (w-[28px] is allowed by the Two-Tier contract by design). This project's lint was green while 41 non-scaling close-button literals shipped. The complement is a verification pass of app-specific computed-style predicates — "every close-X computes --spacing-close-btn × scale", "no two overlays anchor at different offsets" — plus the squint test at multiple scales.
Quick Reference
| Scenario | Technique |
|---|---|
| Deciding what to fix in an existing app | Measure rendered pixels; rank by visual misreading; unify the invisible and stop |
| Pairing within/between gaps on a dense scale | Same ×2 ladder, one rung apart (≥2× automatic); adjacent steps never pair across a boundary |
| Same role, drifting densities (menus, rows, dialog chrome) | One shared class constant per role; fewer tiers than feels natural |
| Grouping fails but within-rhythm is line-height | Common region: a bounded box (border + radius + fill + gap), not more margin |
| Any dimension in a display-scaled app | calc(Npx * var(--display-scale)) unless it is a hairline, a touch floor, a sub-4px bar, vh, em prose, or a platform quirk |
| Two roles share one pixel value | Separate names — coincidental equality must not couple decisions |
| Turning on strict lint in a mature codebase | Per-area ratchet allowlists → pattern-based auto-accept → consolidate → lock |
| Verifying the result | Computed-style predicates + squint test at 0.75/1.0/1.5/2.0, not lint output |
Common AI Mistakes
Treating "all values are valid tokens" as "the layout is correct." The worst measured bug (cards fusing at ratio 1.0×) used nothing but legal tokens. Check relationships, not values.
Fixing an invisible inconsistency with the same energy as a visible one. Four dialog insets or a 2px toolbar delta are maintainability cleanups, not visual bugs — unify quietly; don't spend a role token or a redesign on them.
Shrinking within-card text rhythm to "fix" grouping. If the within-gap is intrinsic line-height, reducing it trades a grouping bug for a readability bug. Switch to a bounded box (common region).
Writing fixed-px arbitraries in a display-scaled app.
w-[28px]freezes at every zoom level while the rest of the UI scales — and sizing lint won't catch it. The sanctioned form isw-[calc(28px*var(--display-scale))]or a token.Renaming or deleting base-scale steps for elegance. A dense middle is a pairing problem, not a scale problem; touching step names multiplies the diff by every existing usage.
Scaling everything, including the exceptions. A scaled 44px touch target shrinks to 33px at 0.75×, dropping below the floor the product chose; a scaled 3px bar lands on a fractional 2.25px; a scaled 1px hairline stops being a hairline. Floors and fixed values are part of the system, not violations of it.
One central lint allowlist for a parallel migration. Every branch edits the same file; the merge conflicts arrive before the improvements do.
When to Use
Good fit
Chrome-dense app UI — editors, dashboards, IDE-like tools, anything where most pixels are toolbars, rows, dialogs, and trees rather than prose.
Display-scaled WebView apps (Tauri, Electron) — the golden rule and its exception list assume a
--display-scalemultiplier exists.Mature codebases facing a spacing migration — the diagnosis-first budget, the unchanged base scale, and the ratchet lint are all migration-tractability devices.
Not needed
Prose and marketing sites — Spacing Philosophy and Tight Token Strategy cover them; role-token machinery for dialog chrome you don't have is overhead.
Greenfield prototypes — measure-then-architect assumes there is something to measure; start with the pairing rule and the golden rule, add the rest when surfaces multiply.
References
Spacing Philosophy — the proximity/ratio rule this article operationalizes for app chrome
Display Scale Strategy — the
--display-scalesystem the golden rule builds onTwo-Tier Size Strategy — element sizing; the display-scale caveat on Tier-2 arbitraries
Component Tokens & Arbitrary Values — the token-vs-arbitrary decision framework the role-token layer applies
Design Token Lint — the enforcement layer the ratchet pattern extends