Stack
A vertical flex container that spaces its children by rhythm rung, not an arbitrary gap value.
Installation
Usage
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" | "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, peer 40px, region 60px, section 96px. The three 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. 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. 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. That component
// ships no bottom margin on purpose and says to put it and the page body in a
// Stack, and until these rungs existed the nearest thing Stack could name was
// `cluster` (16px), which reads visibly tight under a page title: the ladder
// jumped 16 -> 40 across exactly where a heading step belongs. Settling for
// `cluster` because it was the closest available number is the "match a pixel
// count" failure the rules warn about, one rung down.
//
// 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)",
} 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.
function Stack({
className,
gap = "peer",
style,
...props
}: React.ComponentProps<"div"> & { gap?: StackGap }) {
return (
<div
data-slot="stack"
className={cn("flex flex-col", 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 }