Stack
A vertical flex container that spaces its children by rhythm rung, not an arbitrary gap value.
Installation
npx shadcn@latest add https://ui.mattdowney.com/r/stack.jsonUsage
import { Stack } from "@/components/ui/stack"Examples
Default
The rungs side by side. Pick one by the relationship between the blocks, not by the pixels you want: the numbers can be re-tuned later, the relationship stays.
bind (8px)
cluster (16px)
peer (24px)
region (48px)
section (96px)
PageHeader ramp
PageHeader's three sizes step the title, the description, and the gap below them together, so a header keeps the same rhythm whether it opens a page or a small section of one.
sm - title text-sm, description text-1xs, below 8px
Team settings
Manage members, roles, and permissions for this workspace.
md - title text-lg, description text-sm, below 16px
Team settings
Manage members, roles, and permissions for this workspace.
lg - title text-2xl, description text-base, below 24px
Team settings
Manage members, roles, and permissions for this workspace.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| gap | "bind" | "cluster" | "pair" | "trim-pair" | "lines" | "label" | "help" | "field" | "peer" | "region" | "section" | "heading-sm" | "heading-md" | "heading-lg" | "heading-display" | "peer" | Rung on the rhythm ladder: pair 4px, bind 8px, label 8px, help 8px, trim-pair 12px, lines 12px, cluster 16px, field 20px, heading-sm 20px, heading-md 24px, heading-lg 32px, heading-display 40px, peer 40px, region 60px, section 96px. The four heading-* rungs are the step below a heading and the content it introduces, the same tokens PageHeader applies under its own group, for when the heading and its content are siblings rather than parent and child (which is what PageTitleRow needs). Pick one by the heading beside it: a page h1 takes heading-lg, a section h2 heading-md, a display headline heading-display. pair and trim-pair are the same relationship at two renderings: use trim-pair when the title carries cap-trim. It needs the bigger number twice over, once because the trim removes the half-leading a plain box supplies, and again because the trim measures the title's bottom edge at the baseline, so a descender paints into the gap. |
gap"bind" | "cluster" | "pair" | "trim-pair" | "lines" | "label" | "help" | "field" | "peer" | "region" | "section" | "heading-sm" | "heading-md" | "heading-lg" | "heading-display" = "peer"Rung on the rhythm ladder: pair 4px, bind 8px, label 8px, help 8px, trim-pair 12px, lines 12px, cluster 16px, field 20px, heading-sm 20px, heading-md 24px, heading-lg 32px, heading-display 40px, peer 40px, region 60px, section 96px. The four heading-* rungs are the step below a heading and the content it introduces, the same tokens PageHeader applies under its own group, for when the heading and its content are siblings rather than parent and child (which is what PageTitleRow needs). Pick one by the heading beside it: a page h1 takes heading-lg, a section h2 heading-md, a display headline heading-display. pair and trim-pair are the same relationship at two renderings: use trim-pair when the title carries cap-trim. It needs the bigger number twice over, once because the trim removes the half-leading a plain box supplies, and again because the trim measures the title's bottom edge at the baseline, so a descender paints into the gap. |
import * as React from "react"
import { cn } from "@/registry/lib/utils"
const STACK_GAP = {
bind: "var(--spacing-rhythm-bind)",
cluster: "var(--spacing-rhythm-cluster)",
pair: "var(--spacing-rhythm-pair)",
// The trimmed twin of `pair`, for a group whose title carries `cap-trim`. It
// is a bigger number for two reasons that stack. Trimming removes the
// half-leading a plain text box supplies on both sides, so the rung has to
// state what it used to inherit; that part alone put it at 8px, paint-
// identical to `pair`. Then the trim measures the title's bottom edge at the
// BASELINE, and a descender still paints below that, so a title with a `p` or
// a `j` in it eats 0.254em of the gap on Geist and reads cramped where the
// same title without one does not. 12px is what covers both. Reach for a
// component that encodes the
// pairing (PageHeader, DialogHeader and friends) before hand-building a
// heading group; `verify-ui`'s `trim-rung-mismatch` flags one that picks wrong.
// ui-md-allow-trim-rung: this is the rung TABLE, not a call site. Every rung
// is listed here by definition, with no heading in sight.
"trim-pair": "var(--spacing-rhythm-trim-pair)",
// Three or more short lines reading as ONE unit: a value and the lines that
// qualify it, a label over two facts. `pair` is the two-line case and
// `cluster` is where they stop being one unit; this fills the hole between
// them, which the ladder used to jump. Assumes the lines are cap-trimmed,
// since an untrimmed stack already gets this much air from its dead space.
lines: "var(--spacing-rhythm-lines)",
label: "var(--spacing-rhythm-label)",
help: "var(--spacing-rhythm-help)",
field: "var(--spacing-rhythm-field)",
peer: "var(--spacing-rhythm-peer)",
region: "var(--spacing-rhythm-region)",
section: "var(--spacing-rhythm-section)",
// The step between a HEADING and the content it introduces, keyed to the
// heading's size the same way PageHeader's own ramp is: 20 / 24 / 32 for
// sm / md / lg, and 40 for a `display` headline. These are the identical tokens PageHeader applies under its
// heading group when it is given children, exposed here for the case where
// the heading and its content are SIBLINGS rather than parent and child.
//
// That case is not exotic, it is what PageTitleRow requires when the row
// opens a GROUP of blocks (a metric grid, cards, several sections): `cluster`
// (16px) reads tight there, and the ladder jumped 16 -> 40 across exactly
// where a heading step belongs. A row over ONE surface, a single table, is
// different: the surface is the thing it titles, and `cluster` binds them.
//
// Pick by the heading beside it, not by the gap you want: a page h1 takes
// `heading-lg`, a section h2 `heading-md`, a small label `heading-sm`.
"heading-sm": "var(--spacing-rhythm-heading-sm)",
"heading-md": "var(--spacing-rhythm-heading-md)",
"heading-lg": "var(--spacing-rhythm-heading-lg)",
"heading-display": "var(--spacing-rhythm-heading-display)",
} as const
type StackGap = keyof typeof STACK_GAP
// @use-when vertical space between blocks. The layout primitive that OWNS the
// gap, so a child never sets a margin to make room for a sibling. Not Section,
// which is a page band.
function Stack({
className,
gap = "peer",
style,
...props
}: React.ComponentProps<"div"> & { gap?: StackGap }) {
return (
<div
data-slot="stack"
className={cn(
"flex flex-col",
// Two lines bound as one thought (a name over an email): tight
// leading, or each line's own half-leading opens the 4px pair to
// about 11px and the two stop reading as one unit.
gap === "pair" && "[&>*]:leading-tight",
className
)}
style={{ ...style, gap: STACK_GAP[gap] }}
{...props}
/>
)
}
// Gap is set via inline style, not a gap-* utility class: Tailwind inlines
// spacing utility values at compile time (probed in the base rung-token
// commit), so a gap-rhythm-* class would freeze the compiled length and a
// dial or theme override of --spacing-rhythm-* would never reach it.
export { Stack, STACK_GAP, type StackGap }