component

Grid

A modular field grid

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.

ComponentOwnsNever
Containermargins and max-width — the type areacolumns, gutters
Gridcolumns, gutter, lines per field, flowits own margins inside a Container
GridItemone field’s placement and overflowits own column count
GridGuidesthe raster drawn as designdebugging — 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.

Default

Twelve columns and one gutter. Cells take their height from their content, which is the default flow="auto".

12
6
6
4
4
4
---
// 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>

Responsive columns

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.

full
3 / 4
3 / 4
6 / 4
---
// 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>

Row tracks

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.

flowRow tracksUse it for
auto (default)Content height, with a one-line floorOrdinary content. Columns stay exact; the raster sets the vertical floor.
linesExactly one line eachDeliberate field placement — give every item a row/rowSpan.
fieldsAlternating field / gutterAs lines, but auto-placed items land on field boundaries.
auto
auto — taller, because this cell has more text in it
auto
2 × 1 field
1 × 1
3 × 1
placed
placed
auto-placed — lands on the next field
---
// 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.

Fields

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.

3 × 2 fields
3 × 1
3 × 1
6 × 1
---
// 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>

Spacing is counted in lines

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.

gutter 1
gutter 1
gutter 1
gutter 1
gutter 0.5
gutter 0.5
gutter 0.5
gutter 0.5
inset = gutter
inset = gutter
inset = gutter
inset = gutter
---
// 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>

Vertical rhythm

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.

without rhythm

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.

with rhythm

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 board on a surface

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.

full row
half
half
third
third
third
---
// 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>

The type area

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.

type area
3
3
---
// 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>

Drawing the raster

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.

content over the raster
---
// 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");

When content overflows its span

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.

visible — the raster stays exact and the overflow shows. The print behaviour, and the only one with no layout shift.
clip — for media, where the crop is the intent. Everything past the span is simply gone.
auto — a scroller. The gutter appears only when it is needed; `scroll` reserves it 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.

fits in its two lines
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.
---
// 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>
  • Components
  • Blocks