A modular grid in the Müller-Brockmann sense, not a Bootstrap-style column grid. Margins define a type area, the type area divides into fields, a field is an integer number of text lines tall, and the vertical gutter between two fields is one line. Vertical space is counted in lines, never in pixels and never in spacing tokens.
Four components make it up, and each owns exactly one thing.
| Component | Owns | Never |
|---|---|---|
Container | margins and max-width — the type area | columns, gutters |
Grid | columns, gutter, lines per field, flow | its own margins inside a Container |
GridItem | one field’s placement and overflow | its own column count |
GridGuides | the raster drawn as design | debugging — that is the global overlay |
Alignment splits the same way: Container and Grid take align (the inline placement of the box itself), Grid takes alignItems (the block alignment of its children), and a GridItem takes alignSelf.
The reasoning behind the line unit, the attribute table and the flow default is on The Raster.
Twelve columns and one gutter. Cells take their height from their content, which is the default flow="auto".
---
// GENERATED from packages/demos/grid/default.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Grid columns={12}>
<GridItem colSpan={12}>
<Cell label="12" />
</GridItem>
<GridItem colSpan={6}>
<Cell label="6" />
</GridItem>
<GridItem colSpan={6}>
<Cell label="6" />
</GridItem>
<GridItem colSpan={4}>
<Cell label="4" />
</GridItem>
<GridItem colSpan={4}>
<Cell label="4" />
</GridItem>
<GridItem colSpan={4}>
<Cell label="4" />
</GridItem>
</Grid>columns and every span accept a value per breakpoint. colSpan="full" spans whatever the column count happens to be, so a cell stays correct as the raster changes underneath it.
---
// GENERATED from packages/demos/grid/responsive.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Grid columns={{ base: 1, md: 6, lg: 12 }}>
<GridItem colSpan="full">
<Cell label="full" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 3, lg: 4 }}>
<Cell label="3 / 4" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 3, lg: 4 }}>
<Cell label="3 / 4" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 6, lg: 4 }}>
<Cell label="6 / 4" />
</GridItem>
</Grid>flow decides how row tracks are generated, and it is set once, on the Grid. The grid publishes its row pitch as a custom property and every item’s placement arithmetic reads it, so a GridItem cannot disagree with the grid it is in — it has no flow of its own.
flow | Row tracks | Use it for |
|---|---|---|
auto (default) | Content height, with a one-line floor | Ordinary content. Columns stay exact; the raster sets the vertical floor. |
lines | Exactly one line each | Deliberate field placement — give every item a row/rowSpan. |
fields | Alternating field / gutter | As lines, but auto-placed items land on field boundaries. |
---
// GENERATED from packages/demos/grid/row-tracks.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem, GridGuides } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "calc(var(--raster-line) * 2)" })}>
<Grid columns={3} flow="auto">
<GridGuides draw="lines" />
<GridItem>
<Cell label="auto" />
</GridItem>
<GridItem>
<Cell label="auto — taller, because this cell has more text in it" />
</GridItem>
<GridItem>
<Cell label="auto" />
</GridItem>
</Grid>
<Grid columns={3} lines={3} flow="lines">
<GridGuides draw="lines" />
<GridItem col={1} colSpan={2} row={1} rowSpan={1}>
<Cell label="2 × 1 field" emphasis="secondary" />
</GridItem>
<GridItem col={3} colSpan={1} row={1} rowSpan={1}>
<Cell label="1 × 1" />
</GridItem>
<GridItem col={1} colSpan={3} row={2} rowSpan={1}>
<Cell label="3 × 1" />
</GridItem>
</Grid>
<Grid columns={3} lines={3} flow="fields">
<GridGuides draw="lines" />
<GridItem col={1} colSpan={1} row={1}>
<Cell label="placed" />
</GridItem>
<GridItem col={2} colSpan={2} row={1}>
<Cell label="placed" />
</GridItem>
<GridItem colSpan={3}>
<Cell
label="auto-placed — lands on the next field"
emphasis="secondary"
/>
</GridItem>
</Grid>
</div>Never put content-driven cells in flow="lines": an item with no rowSpan gets exactly one 24px track, so a paragraph is clamped to one line and paints over its neighbour.
flow="lines" makes every row track exactly one line, so a field of lines={4} is four lines tall and the gutter between two fields is one more. Items are placed by field coordinate, and an item spanning S fields occupies S·k + (S − 1) line tracks.
---
// GENERATED from packages/demos/grid/fields.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem, GridGuides } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Grid columns={6} lines={4} rows={3} flow="lines">
<GridGuides />
<GridItem col={1} colSpan={3} row={1} rowSpan={2}>
<Cell label="3 × 2 fields" emphasis="secondary" />
</GridItem>
<GridItem col={4} colSpan={3} row={1} rowSpan={1}>
<Cell label="3 × 1" />
</GridItem>
<GridItem col={4} colSpan={3} row={2} rowSpan={1}>
<Cell label="3 × 1" />
</GridItem>
<GridItem col={1} colSpan={6} row={3} rowSpan={1}>
<Cell label="6 × 1" />
</GridItem>
</Grid>Every spacing input — gutter and margin, on Grid and on Container — takes a number of lines, or a spacing token as a deliberate off-raster escape hatch. Padding and gutter should be the same value, and the way to guarantee it is to feed both from one constant rather than two that agree.
---
// GENERATED from packages/demos/grid/spacing.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem } from "@pindoba/astro-grid";
import Panel from "@pindoba/astro-panel";
import Cell from "./_cell.astro";
import { stack } from "@pindoba/styled-system/patterns";
const INSET = 1;
---
<div class={stack({ gap: "calc(var(--raster-line) * 2)" })}>
<Grid columns={4} gutter={1}>
<GridItem>
<Cell label="gutter 1" />
</GridItem>
<GridItem>
<Cell label="gutter 1" />
</GridItem>
<GridItem>
<Cell label="gutter 1" />
</GridItem>
<GridItem>
<Cell label="gutter 1" />
</GridItem>
</Grid>
<Grid columns={4} gutter={0.5}>
<GridItem>
<Cell label="gutter 0.5" />
</GridItem>
<GridItem>
<Cell label="gutter 0.5" />
</GridItem>
<GridItem>
<Cell label="gutter 0.5" />
</GridItem>
<GridItem>
<Cell label="gutter 0.5" />
</GridItem>
</Grid>
<Panel
emphasis="tertiary"
background="surface.ground"
radius="2xl"
padding="none"
>
<Grid columns={4} gutter={INSET} margin={INSET}>
<GridItem>
<Cell label="inset = gutter" />
</GridItem>
<GridItem>
<Cell label="inset = gutter" />
</GridItem>
<GridItem>
<Cell label="inset = gutter" />
</GridItem>
<GridItem>
<Cell label="inset = gutter" />
</GridItem>
</Grid>
</Panel>
</div>The line unit is published as --raster-line at the document root and it inherits, so a nested grid keeps its parent’s rhythm with no configuration; pass line to re-pin it, or line="reset" to return to the token. Text does not sit on the baseline by default, because unitless line-heights on a proportional type scale never land on one.
rhythm opts a subtree in: it bakes the data-rhythm scope and lets the component’s own recipe round its padding, gaps and leading to the line. Panel, Alert, Card, Banner and Grid each take it — smuggling data-rhythm through passThrough sets the scope without the recipe participating in it.
Margins define a type area, the type area divides into fields, and a field is an integer number of text lines tall. The vertical gutter between two fields is exactly one line, which is what makes the arithmetic close.
Margins define a type area, the type area divides into fields, and a field is an integer number of text lines tall. The vertical gutter between two fields is exactly one line, which is what makes the arithmetic close.
A panel that pads itself in whole lines, so its height is a whole number of lines too.
---
// GENERATED from packages/demos/grid/rhythm.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem, GridGuides } from "@pindoba/astro-grid";
import Panel from "@pindoba/astro-panel";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const prose = css({ color: "neutral.text", "& p": { margin: 0 } });
const label = css({
fontSize: "xs",
fontFamily: "mono",
textTransform: "uppercase",
letterSpacing: "0.12em",
color: "neutral.text.muted",
});
---
<div class={stack({ gap: "calc(var(--raster-line) * 2)" })}>
<div class={stack({ gap: "calc(var(--raster-line) * 1)" })}>
<span class={label}>without rhythm</span>
<Grid columns={1}>
<GridGuides draw="lines" />
<GridItem>
<div class={prose}>
<p>
Margins define a type area, the type area divides into fields, and a
field is an integer number of text lines tall. The vertical gutter
between two fields is exactly one line, which is what makes the
arithmetic close.
</p>
</div>
</GridItem>
</Grid>
</div>
<div class={stack({ gap: "calc(var(--raster-line) * 1)" })}>
<span class={label}>with rhythm</span>
<Grid columns={1} rhythm>
<GridGuides draw="lines" />
<GridItem>
<div class={prose}>
<p>
Margins define a type area, the type area divides into fields, and a
field is an integer number of text lines tall. The vertical gutter
between two fields is exactly one line, which is what makes the
arithmetic close.
</p>
</div>
</GridItem>
<GridItem>
<Panel rhythm border="muted" radius="lg" background="surface.peak">
<p class={prose}>
A panel that pads itself in whole lines, so its height is a whole
number of lines too.
</p>
</Panel>
</GridItem>
</Grid>
</div>
</div>A surfaced board is a Panel with padding="none" wrapping a Grid that owns every measurement. The Panel supplies the chrome, the Grid’s own margin supplies the inset, and one constant feeds both that inset and the gutter.
---
// GENERATED from packages/demos/grid/board.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem } from "@pindoba/astro-grid";
import Panel from "@pindoba/astro-panel";
import Cell from "./_cell.astro";
const INSET = 1;
---
<Panel
emphasis="tertiary"
background="surface.ground"
radius="2xl"
padding="none"
>
<Grid columns={{ base: 1, md: 6, lg: 12 }} gutter={INSET} margin={INSET}>
<GridItem colSpan="full">
<Cell label="full row" emphasis="secondary" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 3, lg: 6 }}>
<Cell label="half" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 3, lg: 6 }}>
<Cell label="half" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 2, lg: 4 }}>
<Cell label="third" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 2, lg: 4 }}>
<Cell label="third" />
</GridItem>
<GridItem colSpan={{ base: "full", md: 2, lg: 4 }}>
<Cell label="third" />
</GridItem>
</Grid>
</Panel>Container owns margins and max-width, and a Grid inside one declares only its raster. margin takes a bare line count, the four named sides, or the { block, inline } shorthand; inside and outside are logical, so they flip in RTL, and page="verso" swaps which physical edge each lands on.
---
// GENERATED from packages/demos/grid/container.demo.tsx — edit the source, not this file.
import { Container, GridItem } from "@pindoba/astro-grid";
import Grid from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Container maxWidth="3xl" margin={{ block: 1, inline: 2 }}>
<Grid columns={6}>
<GridItem colSpan={6}>
<Cell label="type area" />
</GridItem>
<GridItem colSpan={3}>
<Cell label="3" />
</GridItem>
<GridItem colSpan={3}>
<Cell label="3" />
</GridItem>
</Grid>
</Container>GridGuides draws the raster from the same variables the layout resolves, so the drawn grid cannot drift from the real one — and it follows its grid’s page, so the insets are right on a verso spread too. It sits below the content like ink under type, and it prints: it is a design element, not a debug aid.
---
// GENERATED from packages/demos/grid/guides.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem, GridGuides } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Grid columns={6} lines={3} rows={2} flow="lines">
<GridGuides draw="both" />
<GridItem col={2} colSpan={4} row={1} rowSpan={2}>
<Cell label="content over the raster" emphasis="secondary" />
</GridItem>
</Grid>For inspecting a layout while you work there is a separate global overlay, which draws above the content and never prints. Toggle it from Devtools → Grid, which also counts the type areas on the page, or from the console:
// every grid draws itself above its content
document.documentElement.toggleAttribute("data-raster-debug");
// every Container's type area outlines its margins
document.documentElement.toggleAttribute("data-raster-debug-area");
CSS cannot round an intrinsic size — round() takes lengths, not max-content — so content-driven height, whole-line quantisation and no clipping cannot all three hold. overflow="visible" (the default) keeps the raster exact and lets the overflow show, which is the print behaviour and the only option with no layout shift; clip is for media, and auto/scroll make the field a scroller, the second reserving the scrollbar gutter unconditionally.
---
// GENERATED from packages/demos/grid/overflow.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem, GridGuides } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Grid columns={3} lines={2} flow="lines">
<GridGuides draw="lines" />
<GridItem col={1} colSpan={1} row={1} rowSpan={1} overflow="visible">
<Cell label="visible — the raster stays exact and the overflow shows. The print behaviour, and the only one with no layout shift." />
</GridItem>
<GridItem col={2} colSpan={1} row={1} rowSpan={1} overflow="clip">
<Cell label="clip — for media, where the crop is the intent. Everything past the span is simply gone." />
</GridItem>
<GridItem col={3} colSpan={1} row={1} rowSpan={1} overflow="auto">
<Cell label="auto — a scroller. The gutter appears only when it is needed; `scroll` reserves it unconditionally." />
</GridItem>
</Grid><Grid autoGrow> is the client-side alternative: it ships a small runtime (observeRasterOverflow from @pindoba/core-grid) that measures each field after paint and grows it by whole lines. In Astro the attribute auto-initialises it (@pindoba/astro-grid/runtime exposes it for markup injected after load); the Svelte, React and Vue Grid start it on mount and stop it on unmount. The server render stays raster-true, so this trades layout stability for rhythm — it belongs below the fold, and never on a masthead.
---
// GENERATED from packages/demos/grid/autogrow.demo.tsx — edit the source, not this file.
import Grid from "@pindoba/astro-grid";
import { GridItem, GridGuides } from "@pindoba/astro-grid";
import Cell from "./_cell.astro";
---
<Grid columns={2} lines={2} flow="lines" autoGrow>
<GridGuides draw="lines" />
<GridItem col={1} colSpan={1} row={1} rowSpan={1}>
<Cell label="fits in its two lines" />
</GridItem>
<GridItem col={2} colSpan={1} row={1} rowSpan={1}>
<Cell label="This field was given two lines and its content needs rather more than that, so the runtime measures it after the first paint and grows the field by whole lines until the text fits inside the span instead of spilling out of it." />
</GridItem>
</Grid>