{"topic":"spacing","format":"markdown","content":"# Spacing & layout\n\nGoal: spacing that encodes relationships. The gap between two elements should\ntell the user whether they belong together — proximity is hierarchy. Most\n\"feels cramped/feels scattered\" complaints are grouping problems, not\nquantity-of-whitespace problems.\n\n## 1. The system\n\nBase unit 4px, favor steps that land on 8: **8, 12, 16, 20, 24, 32, 40, 48,\n64, 80, 96**. If the project has a spacing scale, use its steps exclusively;\nhunt down one-off values (`mt-[13px]`, `margin: 17px`) and snap them to the\nscale.\n\n### Spacing roles: pick by relationship, never by component\n\nOn top of the raw scale (`--ui-space-1`…`8`) sit a few roles, one per\nrelationship. Ask *what is this space between, or inside?* and take that\nrole; never pick a number because of which component you're in. The same\nrelationship gets the same token everywhere, so a page's rhythm holds when\npieces move between screens.\n\n| Role | Value | For |\n|---|---|---|\n| `--ui-pad-tag` | 12 | Inside a badge, tag or count pill: half its 24px height, so the label starts where the round end stops |\n| `--ui-pad-tag-sm` | 8 | A 20px tag, and a tag inside a sentence |\n| `--ui-pad-row` | 12 | Inside a row of a menu or a list in a panel, inline. Never less than the row's radius |\n| `--ui-inset-menu` | 12 | A menu's inset around its rows (rows take panel radius − inset) |\n| `--ui-pad-card` | 16 → 24 | Inside a card or panel |\n| `--ui-pad-section` | 24 → 40 | Inside a screen, a page-level block, a demo stage |\n| `--ui-gap-tight` | 4 | Lines of one thing: a title and its description, a label and its value |\n| `--ui-gap-inline` | 8 | Side by side in a row: icon and label, tag and tag, button and button |\n| `--ui-gap-stack` | 12 | Items of one group: fields in a cluster, the rows of a card |\n| `--ui-gap-group` | 16 → 24 | Groups inside a card or section; a section's heading above its content |\n| `--ui-gap-block` | 16 → 32 | Cards and panels in a layout; a screen's title above its content |\n| `--ui-gap-section` | 48 → 72 | Sections of a screen or page |\n\n- **Fluid by container, on the grid.** The `→` roles grow with the width of\n  the nearest size container (`cqi`; the viewport when there is none) and\n  round to the 4px grid (sections to 8): `round(clamp(min, slope, max), 4px)`.\n  A block in a narrow pane stays compact, the same block on a wide screen\n  breathes, and no value lands between pixels. Make the frame a block lives\n  in (a demo stage, a pane) a size container so its blocks read its width.\n- A role resolves where it's used: on an element that is itself a container,\n  `cqi` reads the container *around* it. Set section and block gaps on a\n  container's children, or accept that its own gap reads the frame.\n- Keep a component's own variable when several rules share one inset\n  (`--pricing-inset: var(--ui-pad-card)`), so they move together.\n- Reach for the raw scale only inside a component's own geometry (a 2px\n  optical nudge, a control's internal inset); never for the space between\n  cards, groups or sections.\n\nRatio rule for grouping: **space within a group ≤ half the space between\ngroups.** A label sits 4–8px from its input; that pair sits 16–24px from the\nnext field; the whole form sits 48–96px from the next section. If inner and\nouter gaps are equal, the eye can't parse structure.\n\n## 2. Vertical rhythm\n\n- Inside sections and stacks, use `gap` (flex/grid) or `space-y-*`, not\n  per-child margins. Margins on children leak and collapse; gaps are owned by\n  the parent.\n- Canonical ladder, as roles: `--ui-gap-inline`/`--ui-gap-stack` inside a\n  control cluster, `--ui-gap-group` heading → body, `--ui-gap-block`\n  between cards and subsections, `--ui-gap-section` between major blocks.\n  (Tailwind: `gap-2`/`gap-3`, `gap-6`, `gap-8`, `gap-12`+.)\n- Section padding scales with a clean multiplier from mobile to desktop —\n  1.5–2×, never an ad-hoc pair:\n\n```\npy-12 md:py-24     /* standard content section */\npy-16 lg:py-24     /* moderate */\npy-20 lg:py-40     /* large marketing section */\n```\n\n- In prose, space *before* a heading should be ~2–3× the space after it, so\n  the heading visually attaches to what it introduces. Modern CSS can derive\n  this instead of hardcoding per element:\n\n```css\n:is(p, ul, ol, blockquote) { margin-block-end: 1em; }\nh2 { margin-block-end: 0.75em; }\n*:has(+ h2) { margin-block-end: 2.5em; }      /* big gap before a heading */\nh2:has(+ h3) { margin-block-end: 0.75em; }    /* unless it's a stacked subhead */\np:has(+ ul) { margin-block-end: 0.25em; }     /* a list belongs to its lead-in */\n```\n\n\n## 3. Horizontal gutters & containers\n\n- One gutter ladder for the whole app: `px-4 sm:px-6 lg:px-8` (conservative\n  app chrome) or `px-5 lg:px-20` (airy marketing). Not both.\n- Better: a gutter token so everything computes from it:\n\n```css\n:root { --gutter: 1rem; }\n@media (min-width: 1024px) { :root { --gutter: 2rem; } }\n.container { max-width: 80rem; margin-inline: auto; padding-inline: var(--gutter); }\n```\n\n- Container widths: ~1280–1400px for full layouts, `max-w-3xl` for section\n  intros, `max-w-2xl`/`65ch` for reading columns. The `min()` idiom replaces\n  max-width + padding pairs: `width: min(1340px, 100% - 2 * var(--gutter))`.\n\n## 4. Alignment\n\n- Edges align or they don't — there is no \"close\". Check that section\n  headings, body copy, and cards share a left edge; ragged starts read as\n  broken.\n- Optical alignment beats box alignment: icons next to text usually need a\n  1px nudge; play-style triangles need centering by visual mass. A text\n  baseline next to a larger sibling wants `items-baseline`, not `items-center`.\n- Icon + label gaps: 6–8px (`gap-1.5`/`gap-2`). Icon size ~1.0–1.2× the text's\n  cap height — 16px icons with 14px text, 20px with 16px.\n- Fixed-position chrome (toasts, FABs) offsets from the viewport by the same\n  step everywhere (16 or 24px), and respects safe-area insets on mobile:\n  `bottom: max(1rem, env(safe-area-inset-bottom))`.\n\n## 5. Component-level spacing\n\n- **Buttons**: horizontal padding ≈ 2–2.5× vertical (e.g. `px-4 py-2`).\n  Heights come from one scale, 8px apart: 32 / 40 / 48px\n  (`--ui-control-sm/md/lg`). Steps closer than 8px read as a mistake beside\n  each other, and a jump of 14 reads as two different families. Everything\n  that sits in a row with a button takes a height from this scale, including\n  composite controls: a segmented control or a switch is measured whole,\n  track included, not by its inner pill. Icon-only buttons are\n  square, same height as their text siblings.\n- **Buttons are the standard control height (40).** Page actions, card and\n  row actions, dialog footers, banners, empty states, form submits and demo\n  toolbars all use the default size. The small size (32) is only for a\n  button inside another control or a compact control strip, where 40 would\n  break the control: Copy in a code block's header, send or attach in a\n  prompt input, an icon button in a dense toolbar or a row's overflow menu.\n  When in doubt, use the default. A row that holds a button is at least 40\n  tall; a ghost button that lines up with text beside it pulls back by its\n  20px padding, not the small size's 14.\n- **Controls in one row share one height.** A toolbar, a filter bar, a\n  form's actions or a demo's controls pick one step and every control in\n  the row takes it: a search field (40px) sets its select and segmented\n  control to 40, not 32 beside it. Only a control nested inside another\n  (a field's clear button, the copy button inside a command pill) is\n  smaller, inset evenly on every side.\n- **Cards**: one internal padding value per card (`--ui-pad-card`), not\n  different values per side. Media that bleeds to the card edge needs the\n  card's `overflow-hidden` and radius handling (see surfaces.md).\n- **Menus and lists in a panel** (dropdown and context menus, select and\n  combobox lists, the command palette, a model picker): rows are rounded\n  rectangles, never pills. The panel insets its rows evenly and each row\n  takes the concentric radius, panel radius − inset (a menu: 24 − 12 = 12;\n  a field's list: 14 − 4 = 10). Row text sits `--ui-pad-row` (12) in, at\n  least the row's radius, so it never runs into the curve. A group label,\n  a search row or a status line starts its text where the rows' text does.\n  A pill row on a short row puts the text inside the round end and reads\n  cramped however much padding it gets.\n- **Inputs**: same height ladder as buttons so they line up in rows. Label\n  4–8px above the field; help/error text 4–6px below.\n- **Optical padding**: a control's text sits the same distance in on every\n  field in a form (14px). A trailing chevron or icon carries air inside its\n  own box, so the padding after it is smaller (10px) to read as even.\n- **Lists/tables**: row padding consistent, `py-2.5`–`py-3` for dense UI,\n  `py-4` for comfortable. Dividers OR spacing — using both is redundant.\n- **Connectors (steppers, timelines, pipelines)**: a line between two nodes\n  stops one fixed gap short of each node's outermost edge, the same at both\n  ends and in every state. So nothing state-driven grows past a node toward\n  its line: a running or selected node changes its own fill and edge, not a\n  ring or halo outside it, and a selection tint sits behind the text, never\n  around the node the line meets. A corner badge may hang off a node only\n  away from the line's axis. A moving highlight is the line itself\n  brightening (tint the line, move a brighter stretch of it), never a\n  second stroke laid over a grey one.\n\n## 6. Breathing room failures to hunt\n\n- Text touching container edges (missing padding on mobile).\n- Buttons/badges where the text nearly touches the border — a pill with\n  `px-2` at 14px text is always cramped; start at `px-3`\n  (`--ui-pad-tag`: half the pill's height).\n- Headings glued to the content above them instead of below (the 2:1 rule\n  inverted).\n- Full-bleed sections whose *content* forgot the gutter.\n- Elements centered vertically in a taller sibling when they should be\n  top-aligned (multi-line text next to an avatar or checkbox: align to the\n  first line, e.g. `items-start` + small top offset).\n\n## 7. Responsive collapse\n\n- **Components adapt to their container, not the viewport.** A component\n  that can sit in a narrow pane (a list, a card, a form row) makes itself a\n  size container and folds with `@container`; viewport `@media` is for page\n  chrome. A container query can't style its own container, so set the\n  variables on its child. A grid's columns are `minmax(0, 1fr)`, never a\n  bare `auto` that grows to the widest content, and nothing has a fixed\n  width or `min-width` wider than a 320px phone.\n- **On phones:** primary actions are 40px+ to the touch (grow a smaller\n  control's hit area with a pseudo-element on `pointer: coarse`, not its\n  look) and within the thumb's reach, spanning the card when stacked;\n  prose tables stack, data tables scroll in their own box with the first\n  column pinned; sheets, dialogs and drawers come up from the bottom, clear\n  of `env(safe-area-inset-bottom)`; a bottom bar spans the width with its\n  items sharing it evenly. A row of controls that can't fit wraps whole\n  controls or scrolls inside its own track, never one word per line.\n\n- Multi-column grids collapse in content order — verify the order still makes\n  sense stacked. `lg:grid-cols-2` with a media-right layout often needs\n  `order-first` on the media at mobile.\n- Reduce, don't remove: section padding halves on mobile, it doesn't vanish.\n- Anything `sticky` on desktop should usually stop being sticky on short\n  viewports (`lg:sticky` rather than `sticky`).\n- Never let horizontal scroll leak: wide tables and code blocks get their own\n  `overflow-x-auto` wrapper; the page body never scrolls sideways.\n- A table of prose (states, props, options) stacks when its box is too\n  narrow for its columns: each row becomes a block, its first cell the title\n  and every other value under its column's name. Sideways scroll is for\n  data a person compares across columns, never for sentences.\n"}