Grid
A CSS-grid layout primitive with static and responsive column counts, gap presets, flow control, and per-cell placement via GridItem. Polymorphic render, RTL, and no hard-coded tokens.
Playground
Installation
pnpm add @tessinaui/uiUsage
import { Grid, GridItem } from "@tessinaui/ui";{/* Fixed 3-column grid */}
<Grid cols={3} gap="md">
<Card>One</Card>
<Card>Two</Card>
<Card>Three</Card>
</Grid>
{/* Responsive columns */}
<Grid cols={{ base: 1, sm: 2, md: 3, lg: 4 }} gap="sm">
{items.map((item) => <Card key={item.id}>{item.title}</Card>)}
</Grid>
{/* Cell placement with GridItem */}
<Grid cols={4} rows={3} gap="sm">
<GridItem colSpan={2} rowSpan={2}>
<Card>Featured</Card>
</GridItem>
<Card>1</Card>
<Card>2</Card>
<Card>3</Card>
<Card>4</Card>
</Grid>
{/* Explicit line placement */}
<Grid cols={6}>
<GridItem colStart={2} colEnd={5}>
<Hero />
</GridItem>
</Grid>
{/* Dense flow backfills gaps */}
<Grid cols={4} flow="dense" gap="sm">
<GridItem colSpan={2}>Wide</GridItem>
<Card>Auto</Card>
<Card>Auto</Card>
</Grid>Examples
Default
A fixed three-column grid with the default md gap.
Responsive columns
One column on a phone through four on a laptop.
Responsive spans
Spans respond too, not just the column count — and a span of 0 removes a cell at that width rather than squeezing it.
Auto-fit columns
minChildWidth fits as many columns as will hold a child that wide, so the count follows the container rather than the viewport.
Gap
The gap scale, including the px hairline, and the two per-axis overrides.
Guides
Hairline rules between cells instead of a gap.
Featured cell
A hero spanning every column, and a featured tile two wide by two tall.
Dense flow
flow="dense" backfills the holes that wide cells leave behind.
Media gallery
Square tiles at a px gap, under a section header the grid does not own.
Stats
Two columns on a phone, four on a laptop — no cards, just rhythm.
Pricing
Equal-width, equal-height columns. alignItems="stretch" is the load-bearing part.
Alignment
Five cards in four columns leaves an orphan row; content alignment decides where it sits.
Horizontal scroll
Two rows flowing sideways, with the next column peeking at the edge.
Template areas
A layout you can read as a picture.
Subgrid
Cards that adopt their parent's rows so their titles and bodies line up.
Order
Visual reordering — and the reason to be careful with it.
Semantic list
render puts the grid on a <ul> and each cell on an <li> — with the explicit list roles WebKit needs once the layout is a grid.
RTL
Tracks follow the writing direction on their own.
Loading
A skeleton grid that holds the same frame as the loaded one.
Columns
Pass a number for a fixed count, or a breakpoint object for responsive layouts.
<Grid cols={3}>…</Grid>
<Grid cols={{ base: 1, sm: 2, md: 3, lg: 4, xl: 6 }}>…</Grid>
<Grid cols="subgrid">…</Grid>Supported column counts: 1 through 12, plus "subgrid".
Supported breakpoints: base, sm, md, lg, xl.
Keys are forward-filled: { base: 1, lg: 3 } is one column below lg and three from lg up, with no gap in the middle. An object with no base key leaves the widths below its first key unset — occasionally what you want, usually not.
How a responsive value is emitted
Every rung carries its own ceiling — max-sm: · sm:max-md: · md:max-lg: · lg:max-xl: · xl: — so no viewport width matches two of them. A plain min-width ladder (sm:grid-cols-2 md:grid-cols-3) leaves the winner to stylesheet emission order, which is not something a component can guarantee. You do not have to think about this to use the prop; it is why the class list is longer than you might expect.
Auto-fit columns
minChildWidth replaces cols with a track that repeats as often as the container allows:
<Grid minChildWidth="16rem">…</Grid>
<Grid minChildWidth="16rem" autoRepeat="fit">…</Grid>"fill" (the default) keeps each column at its natural width and leaves the remainder empty. "fit" stretches the columns that exist to consume it. Reach for this when the right column count is a function of available space rather than of device size.
Rows
| Value | CSS mapping |
|---|---|
1–6 | grid-rows-1 – grid-rows-6 |
"subgrid" | grid-rows-subgrid — adopt the parent's rows |
{ base, sm, … } | Responsive, banded as above |
Explicit row counts are usually only needed with rowSpan, rowStart/rowEnd, or a column flow.
A subgrid must be a grid item: one wrapper element between it and its parent and it inherits nothing. Use GridItem render={<Grid rows="subgrid" />} to keep them the same element.
Gap
Eight presets on Tailwind's spacing scale.
| Value | Tailwind | px |
|---|---|---|
"px" | gap-px | 1 |
"none" | gap-0 | 0 |
"xs" | gap-1 | 4 |
"sm" | gap-2 | 8 |
"md" (default) | gap-4 | 16 |
"lg" | gap-6 | 24 |
"xl" | gap-8 | 32 |
"2xl" | gap-12 | 48 |
rowGap and columnGap accept the same values and override gap on their axis. All three accept a breakpoint object.
Guides
guides draws a hairline rule between cells and zeroes the gap:
<Grid cols={3} guides>…</Grid>The rule is a border on each cell, not a painted channel behind the grid — a background would show through as a solid block wherever the last row runs short. The container clips the rules that reach its own edge, so only the internal grid is drawn; wrap it in a bordered element if you want an outer frame too. guides and the gap props are mutually exclusive, and setting both logs a development warning.
Flow and implicit tracks
| Value | CSS mapping |
|---|---|
"row" | grid-flow-row — fill row by row (default) |
"col" | grid-flow-col — fill column by column |
"dense" | grid-flow-dense — backfill gaps with smaller items |
"row-dense" | grid-flow-row-dense |
"col-dense" | grid-flow-col-dense |
autoCols and autoRows size the tracks a flow creates implicitly — "auto", "min", "max" or "fr". A two-row horizontal scroller is rows={2} flow="col" autoCols="max".
Alignment
| Prop | CSS | Values |
|---|---|---|
alignItems | align-items | start / center / end / stretch / baseline |
justifyItems | justify-items | start / center / end / stretch |
alignContent | align-content | start / center / end / stretch |
justifyContent | justify-content | start / center / end / stretch |
Template areas
<Grid areas={["head head", "side main"]} cols={2}>
<GridItem area="head">…</GridItem>
<GridItem area="side">…</GridItem>
<GridItem area="main">…</GridItem>
</Grid>Each string is one row; quotes are added for you.
Two things to know. The template sets the track count, so a cols prop alongside it has nothing left to decide — a two-area row is a two-column grid whatever cols says. And areas is not responsive: it is one inline grid-template-areas value at every width. When the shape has to change at a breakpoint, use cols with responsive spans instead.
Areas also move cells visually without moving them in the DOM — see Accessibility below.
Inline
inline={true} swaps grid for inline-grid so the container flows alongside surrounding inline content.
Polymorphic render
Clone a semantic element while keeping Grid styling. The classes and data-slot land on your element; no wrapper is inserted — which matters, because a wrapper between a grid and its cell makes the cell's placement inert.
<Grid render={<ul aria-label="Tiles" />} cols={3} gap="md">
<GridItem render={<li />}>One</GridItem>
<GridItem render={<li />}>Two</GridItem>
</Grid>A render host keeps its own ref and its own data-slot; both are merged rather than replaced.
GridItem
Per-cell placement and alignment. GridItem is optional — you can always apply col-span-* / row-span-* directly with className.
colSpan / rowSpan
| Value | CSS |
|---|---|
"auto" | col-auto / row-auto |
"full" | col-span-full / row-span-full |
1–12 | col-span-n / row-span-n |
0 | Hides the cell — only inside a breakpoint object |
{ base, sm, … } | Responsive, banded as above |
colStart / colEnd / rowStart / rowEnd
Explicit grid-line placement, "auto" or 1–13. colStart also accepts a breakpoint object, which is how you express a responsive offset.
<GridItem colStart={2} colEnd={5}>…</GridItem>
<GridItem colStart={{ base: "auto", md: 2 }}>…</GridItem>order
"none" · "first" · "last" · 1–12. Visual only — see Accessibility.
area
The name of an area declared in the parent's areas.
alignSelf / justifySelf
Per-cell override of the parent's alignItems / justifyItems.
<GridItem alignSelf="end" justifySelf="center">…</GridItem>RTL
dir="rtl" is forwarded to the DOM node, and CSS grid does the rest: columns lay out right-to-left and colStart counts from the right. The component contains no ltr: / rtl: variants — those compile to a :lang() or [dir]-descendant test that matches both ways round once a grid is nested inside another direction.
Accessibility
- Grid is a structural primitive — it applies no ARIA roles.
- Use
renderto mount semantic HTML (<ul>,<section>,<main>) when the grouping has meaning, so the structure is announced rather than inferred. - A
<ul>laid out as a grid needsrole="list", and its cellsrole="listitem". The roles look redundant and are not: grid layout makes the childrendisplay: flex, and WebKit drops the implicit list semantics of any<li>that is no longerdisplay: list-item. Without them VoiceOver announces the example above as "Departments group" and reads the cells as loose text with no "1 of 6". axe passes it either way, so this is one to take on trust rather than on a green gate. - Visual order is not DOM order.
order,areas,colStartandrowStartall move cells on screen without moving them in the markup, and screen readers and the Tab key follow the markup. Where the sequence carries meaning, reorder the markup too. Shopify deprecated its ownareasAPI over exactly this mismatch; ours keeps it, and puts the responsibility here. - A cell hidden with
colSpan={{ base: 0 }}isdisplay: none, so it is removed from the accessibility tree as well as the layout. That is usually right for decorative cells and wrong for content — if the content matters at every width, reflow it instead of hiding it. guidesdraws decoration, not structure: it carries no semantics and needs none.
When to use Grid vs. Flex
| Use Grid when… | Use Flex when… |
|---|---|
| You have a two-dimensional layout | You have a one-dimensional layout |
| Items should align on both axes | Only the main axis matters |
| You need equal-width columns without basis math | Item widths vary by content |
You want explicit cell placement (colStart, rowSpan) | Items flow naturally |
A list/grid view toggle is two layouts, not one: swap Grid for Stack or Flex and keep the container's padding identical so the switch does not shift the page.
API Reference
Grid Props
| Prop | Type | Default | Description |
|---|---|---|---|
cols | 1..12 | "subgrid", or a breakpoint object | — | Column count |
rows | 1..6 | "subgrid", or a breakpoint object | — | Row count |
gap | "px" | "none" | "xs" | "sm" | "md" | "lg" | "xl" | "2xl", or a breakpoint object | "md" | Both-axis gap |
rowGap | Same as gap | — | Block-axis gap (overrides gap) |
columnGap | Same as gap | — | Inline-axis gap (overrides gap) |
minChildWidth | string | — | Fit as many columns as hold a child this wide; replaces cols |
autoRepeat | "fill" | "fit" | "fill" | How minChildWidth repeats |
autoCols | "auto" | "min" | "max" | "fr" | — | grid-auto-columns |
autoRows | "auto" | "min" | "max" | "fr" | — | grid-auto-rows |
guides | boolean | false | Hairline rules between cells; zeroes the gap |
areas | string[] | — | grid-template-areas, one string per row |
flow | "row" | "col" | "dense" | "row-dense" | "col-dense" | — | grid-auto-flow |
alignItems | "start" | "center" | "end" | "stretch" | "baseline" | — | align-items |
justifyItems | "start" | "center" | "end" | "stretch" | — | justify-items |
alignContent | "start" | "center" | "end" | "stretch" | — | align-content |
justifyContent | "start" | "center" | "end" | "stretch" | — | justify-content |
inline | boolean | false | Use inline-grid |
render | ReactElement | — | Polymorphic target |
dir | "ltr" | "rtl" | — | Text direction |
className | string | — | Extra classes |
GridItem Props
| Prop | Type | Default | Description |
|---|---|---|---|
colSpan | "auto" | "full" | 1..12, or a breakpoint object (0 allowed) | — | grid-column: span n |
rowSpan | "auto" | "full" | 1..12, or a breakpoint object | — | grid-row: span n |
colStart | "auto" | 1..13, or a breakpoint object | — | grid-column-start |
colEnd | "auto" | 1..13 | — | grid-column-end |
rowStart | "auto" | 1..13 | — | grid-row-start |
rowEnd | "auto" | 1..13 | — | grid-row-end |
order | "none" | "first" | "last" | 1..12 | — | Visual order only |
area | string | — | Named area from the parent's areas |
alignSelf | "auto" | "start" | "center" | "end" | "stretch" | "baseline" | — | align-self |
justifySelf | "auto" | "start" | "center" | "end" | "stretch" | — | justify-self |
render | ReactElement | — | Polymorphic target |
dir | "ltr" | "rtl" | — | Text direction |
className | string | — | Extra classes |
Notes
- No intrinsic padding. Grid controls layout only — add padding, borders and background via
className, or compose with Card or Surface. - Data attributes. The container carries
data-slot="grid"and each celldata-slot="grid-item", so tests and agents have a stable hook. Arenderhost that names its own slot keeps it. - No SSR caveats. Grid has no hooks beyond ref merging; it renders on the server.
- Static class names. Every class is a literal in the source, so Tailwind's scanner sees them all. Interpolating a class name — or a variant prefix — would compile to nothing.
- Masonry is out of scope. A staggered, variable-height wall is not CSS grid: no surveyed design system implements it with one, and neither do we. Use CSS
columns(className="columns-2 gap-4"withbreak-inside-avoidchildren) when you want that look, and Grid when you want cells that line up. - Arbitrary values. For anything outside the presets, drop to
className="grid-cols-[repeat(3,8rem)]". Pass the whole ladder yourself if it is responsive — mixing a bare utility with a breakpoint variant of the same property is decided by emission order.