---
title: Grid
description: A responsive grid with no breakpoints in it. One number decides the column count at every width.
source: https://adamjurek.com/components/grid
---

## Home


Four cards that become one column, without a media query anywhere.

<!-- ::start:showcase demo="grid" height="420" -->
<!-- ::end:showcase -->

## The whole mechanism

```css
grid-template-columns: repeat(auto-fit, minmax(var(--grid-min), 1fr));
```

`min` is the narrowest a column may get. Columns fit as many as will fit at that
width and share what is left, so the same grid is four across on a desktop and
one across at 320 and nobody wrote either number down.

## Why not a media query

Not brevity. A media query asks about the viewport, and a grid three levels
inside a sidebar does not care about the viewport - it cares about the width it
was given. `auto-fit` asks the right question, so the same component behaves
correctly in a place its author never saw.

That is also why this reflows correctly inside the Showcase at 320 without the
Showcase knowing anything about it.

## Where this is used

| Where | What for |
| --- | --- |
| Any `.md` on this site | the `::start:grid` block |
| `packages/ui/docs/nav-bar/index.md` | the two-up explanation of wide and narrow |
| `.nav-panel-list` | the same `auto-fit` rule, inlined, so the nav has no dependency on this |


## Install

<!-- ::start:tabs -->

### TanStack

```shell
tanstack add https://adamjurek.com/r/tanstack/grid.json
```

### shadcn

```shell
pnpm dlx shadcn@latest add https://adamjurek.com/r/shadcn/grid.json
```

### pnpm

```shell
pnpm add @sushindustries/ui @sushindustries/atoms
```

<!-- ::end:tabs -->

## What you get

| | |
| --- | --- |
| Version | 0.1.0 |
| Category | layout · Page structure |
| Files | `grid.tsx` |
| Dependencies | None |
| Also installs | `spacer` |
| Tags | grid, responsive, no-deps |

> [!NOTE] No runtime dependencies
> It brings nothing with it beyond the stylesheet.

## Get Started


Install commands are on Home, attached from the registry - they are not written
here, because a second copy is a copy that goes stale. This tab starts after the
install worked.

## Use it

```tsx
import { Grid } from "@sushindustries/ui";

export function Example() {
	return (
		<Grid min="14rem" gap={4}>
			<div className="card p-4">One</div>
			<div className="card p-4">Two</div>
			<div className="card p-4">Three</div>
		</Grid>
	);
}
```

## What you should see

Three boxes side by side, each at least 14rem wide, sharing whatever space is
left over. Narrow the window and they wrap - two per row, then one - without a
visible jump at a fixed width, because `auto-fit` recomputes the count from
whatever space `Grid`'s own container has, not from the viewport.

## If nothing happens

`grid-auto`, the class this component writes, comes from
`@sushindustries/atoms`. Without that stylesheet loaded, the children still
render, just stacked in document order rather than gridded - `Grid` sets no
inline layout styles beyond the `--grid-min` custom property.


## Guides


## When to pin the count

```tsx
<Grid columns={2}>
```

For content that is genuinely paired. A before and an after that reflow to one
column stop being a comparison, so a pinned grid holds until the narrow
breakpoint and then gives up all at once rather than degrading through an
awkward middle.

If you find yourself pinning the count for anything else, `min` is the thing you
actually wanted.

<!-- ::start:spacer size="6" rule="true" -->
<!-- ::end:spacer -->

## In Markdown

```text
<!-- ::start:grid min="18rem" gap="4" -->

Anything at all, including other blocks.

<!-- ::end:grid -->
```

This exists because Markdown gives an author no way to say "these things go
side by side". Without it the workaround is an HTML table, which then has to be
undone at every width.

## Spacing

`gap` is a step on the scale, not a pixel value. There is no arbitrary-value
syntax here and that is deliberate: a short scale is what makes a set of
sections look measured rather than assembled.


## API


<!-- generated:api -->

## Props

| Prop | Type | Default | Does |
| --- | --- | --- | --- |
| `children?` | `ReactNode` | - |  |
| `min?` | `string` | `"16rem"` | Narrowest a column may get before the grid drops one. The whole layout, in one number. `auto-fit` plus `minmax` works out the column count from the space available, so there is no breakpoint to write and no count to keep in step with a media query. |
| `gap?` | `Space` | `4` | A step on the scale, rendered as `data-gap`. Between rows as well as columns. |
| `columns?` | `2 \| 3 \| 4` | - | Fixed column count, for the cases where content really is paired. |
| `className?` | `string` | - |  |

<!-- /generated:api -->

## Notes

`columns` and `min` are not combined - setting `columns` replaces the
`auto-fit` template outright, so `min` is written to the element either way
but goes unused while `columns` is set. A fixed count also behaves
differently below the 860px breakpoint: it collapses straight to one column,
where the `min`-driven grid has already been narrowing one column at a time
the whole way down.

A direct child can claim more than one track with `data-span="2"`,
`data-span="3"` or `data-span="full"`, regardless of which prop is set. Spans
collapse back to `auto` below 860px along with everything else, so a span can
never force a squeeze on a phone.


## Examples


Examples are the tab where the component is shown doing a job, not
demonstrating a prop. The API tab already lists the props.

<!-- ::start:showcase demo="grid" height="420" -->
<!-- ::end:showcase -->

Press Compare. The frames are real viewports, so a layout that breaks at 320
breaks here too rather than in somebody's hands.

## In a page

```tsx
import { Grid, Icon } from "@sushindustries/ui";
import type { IconName } from "@sushindustries/ui";

const features: { icon: IconName; label: string }[] = [
	{ icon: "layers", label: "Components" },
	{ icon: "grid", label: "Layout" },
	{ icon: "book", label: "Docs" },
];

export function FeatureGrid() {
	return (
		<Grid min="16rem" gap={5} className="section">
			{features.map((feature) => (
				<div key={feature.label} className="card p-5">
					<Icon name={feature.icon} size={24} />
					<p className="mt-3 font-semibold">{feature.label}</p>
				</div>
			))}
		</Grid>
	);
}
```

## What this example is not

The card markup (`.card`, spacing utilities) is this site's own atoms, not
part of `Grid` - the component only lays its children out, it does not style
them. Nothing here claims a `data-span`, so every card is one track wide; a
hero card spanning the full row would set `data-span="full"` on itself.
