zudo-css-wisdom
GitHub repository

Type to search...

to open search from anywhere

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:

  1. 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."

  2. 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:

PieceWhat it decides
Rendered-pixel diagnosiswhat is actually broken, and what to leave alone
The two-ladder pairing rulewhich two gaps may meet at a grouping boundary
Role tokenswhat a recurring surface is called, so it stops drifting
Common regionhow to group when the within-group rhythm is not yours to set
The display-scale contractwhich 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:

RankSurfaceMeasuredVerdict
1Timeline cardsbetween-card ~25px vs within-card rhythm ~24px (ratio ≈ 1.0×)cards fuse into one list
2Menu rowsfive densities for one role: h25/h29/h32/h49/h56density soup
3Command palettepy-12/h49 vs dropdown py-4/h292× outlier row
4Dividers~20px above / 12px below the lineambiguous separator role
Dialog insets 24/20/16/12one dialog visible at a timeinvisible — deprioritize
2px toolbar / 1px badge deltasbelow perceptioninvisible — 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-groupValid between-group (same ladder, ≥1 rung up)Ratio
4 (A)8 / 16 / 322× / 4× / 8×
6 (B)12 / 24 / 482× / 4× / 8×
8 (A)16 / 322× / 4×
12 (B)24 / 482× / 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.

Two-Ladder Pairing — Adjacent Steps vs Same Ladder, One Rung Up

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.

Density Soup — Five Densities for One Role vs Two Named Tiers

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

Intrinsic Rhythm: Gap Ratio Fails, Common Region Wins

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:

ExceptionTreatmentWhy
Hairlines (1px borders, dividers, tree connectors)fixed 1px, never multipliedmust stay crisp at every scale; only their position scales
Touch-target floormax(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 anchorsplain vh, not scaledviewport-relative by design
Prose contentem-based, out of the token system entirelyrides the font size, which scales on its own axis
Platform-quirk px (16px iOS zoom threshold, safe-area math)fixed, documented in-fileimposed by the platform, not the design
Display Scale — Token Scales, Arbitrary Freezes, Exceptions Hold

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 valueMath.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:

PatternShapeNotes
close-btnone square hit-box size for every dialog close-Xthe single most copy-pasted literal in the app (dozens of w-[28px] h-[28px] sites) — tokenize before it spreads
touch-min44px floored minimum for every interactive row/buttonthe floor is the point — see exceptions table
card-gapgap between bounded cards on board-like surfacespairs with the common-region recipe
section-gapthe between-group boundary inside dialog/settings bodiesnames your validated within↔between north-star (here: 10↔28 ≈ 2.8×)
tree-indent familybase + step × depth, plus a fixed 1px connectorone helper for every tree; JS-rounded per depth
hint-indentindent aligning help text under a checkbox labeloften an optical-fudge composite (checkbox 16 + gap 8 + 4px) — document the composition in a comment
accent-bar3px priority stripe / drag indicatorfloored + rounded, consumed as a border-width or w/h
icon-btn-minisecond-tier small icon button below the standard squareprevents 22/24px near-duplicates
overlay-offset-topone vh anchor for the whole type-ahead overlay familyunify 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

ScenarioTechnique
Deciding what to fix in an existing appMeasure rendered pixels; rank by visual misreading; unify the invisible and stop
Pairing within/between gaps on a dense scaleSame ×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-heightCommon region: a bounded box (border + radius + fill + gap), not more margin
Any dimension in a display-scaled appcalc(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 valueSeparate names — coincidental equality must not couple decisions
Turning on strict lint in a mature codebasePer-area ratchet allowlists → pattern-based auto-accept → consolidate → lock
Verifying the resultComputed-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 is w-[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-scale multiplier 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 sitesSpacing 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

Revision History

CreatedUpdated