Layout
Grid
A grid of fixed columns, or as many as fit at a minimum width, with token gaps and spanning items.
- Built on
CSS gridauto-fitContainer queries- Client JavaScript
- None
- Works without React
- Yes, as HTML and CSS
- Source, gzipped
- 1.8 kB
- Dependencies
- React
Try its options
Loading its options.
Code
import { Grid, GridItem } from "@/components/equip/grid";
const cell = {
display: "grid",
placeItems: "center",
minHeight: 36,
borderRadius: "var(--eq-radius-s)",
background: "var(--eq-tint-strong)",
color: "var(--eq-ink-2)",
fontSize: "var(--eq-text-xs)",
};
export default function Demo() {
return (
<Grid columns={3} gap={2} style={{ width: 208 }}>
<GridItem span={2} style={cell}>Revenue</GridItem>
<GridItem style={cell}>Churn</GridItem>
<GridItem style={cell}>Seats</GridItem>
<GridItem style={cell}>MRR</GridItem>
<GridItem style={cell}>NPS</GridItem>
</Grid>
);
}Your versionchange its options in Look, This part
HTMLworks in any framework, with the part's CSS
<div class="eq-grid" style="--_columns:3;width:208px" data-mode="fixed" data-gap="2">
<div class="eq-grid-item" style="--_span:2;display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)" data-span="n">Revenue</div>
<div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">Churn</div>
<div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">Seats</div>
<div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">MRR</div>
<div class="eq-grid-item" style="display:grid;place-items:center;min-height:36px;border-radius:var(--eq-radius-s);background:var(--eq-tint-strong);color:var(--eq-ink-2);font-size:var(--eq-text-xs)">NPS</div>
</div>Examples
A product grid
min="10rem" fits as many columns as the space allows, from one on a phone to five on a wide screen, with no media query.
- Linen overshirt€89
- Merino crew€120
- Canvas tote€35
- Wool beanie€29
- Cord trousers€95
- Rain shell€160
Code
import { Grid, GridItem } from "@/components/equip/grid";
const products = [
{ name: "Linen overshirt", price: "€89" },
{ name: "Merino crew", price: "€120" },
{ name: "Canvas tote", price: "€35" },
{ name: "Wool beanie", price: "€29" },
{ name: "Cord trousers", price: "€95" },
{ name: "Rain shell", price: "€160" },
];
export default function Example() {
return (
<Grid as="ul" min="10rem" gap={4} rowGap={5} style={{ width: "100%" }}>
{products.map((product) => (
<GridItem as="li" key={product.name}>
<div
style={{
aspectRatio: "1",
marginBottom: 8,
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-tint-strong)",
}}
/>
<div style={{ fontSize: "var(--eq-text-m)", fontWeight: "var(--eq-weight-strong)" }}>{product.name}</div>
<div style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>{product.price}</div>
</GridItem>
))}
</Grid>
);
}HTMLworks in any framework, with the part's CSS
<ul class="eq-grid" style="--_min:10rem;width:100%" data-mode="fit" data-gap="4" data-row-gap="5">
<li class="eq-grid-item">
<div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Linen overshirt</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€89</div>
</li>
<li class="eq-grid-item">
<div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Merino crew</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€120</div>
</li>
<li class="eq-grid-item">
<div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Canvas tote</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€35</div>
</li>
<li class="eq-grid-item">
<div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Wool beanie</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€29</div>
</li>
<li class="eq-grid-item">
<div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Cord trousers</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€95</div>
</li>
<li class="eq-grid-item">
<div style="aspect-ratio:1;margin-bottom:8px;border-radius:var(--eq-radius-m);background:var(--eq-tint-strong)"></div>
<div style="font-size:var(--eq-text-m);font-weight:var(--eq-weight-strong)">Rain shell</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">€160</div>
</li>
</ul>A dashboard with spans
columns=4 with min caps the grid at four columns; below 32rem the spanning panel runs full width instead of making columns the grid does not have.
Code
import { Grid, GridItem } from "@/components/equip/grid";
const panel = {
padding: 12,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-surface)",
};
const stats = [
["Active users", "18,204"],
["Conversion", "3.8%"],
["Refunds", "12"],
["Uptime", "99.98%"],
];
export default function Example() {
return (
<Grid columns={4} min="9rem" gap={3} style={{ width: "100%" }}>
{stats.map(([label, value]) => (
<GridItem key={label} style={panel}>
<div style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{label}</div>
<div style={{ fontSize: "var(--eq-text-l)", fontWeight: "var(--eq-weight-strong)" }}>{value}</div>
</GridItem>
))}
<GridItem span={3} style={{ ...panel, minHeight: 120 }}>
<div style={{ fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>Signups this week</div>
<div style={{ height: 72, marginTop: 8, borderRadius: "var(--eq-radius-s)", background: "var(--eq-tint)" }} />
</GridItem>
<GridItem style={panel}>
<div style={{ fontSize: "var(--eq-text-s)", fontWeight: "var(--eq-weight-strong)" }}>Top plan</div>
<div style={{ fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>Team, 61% of seats</div>
</GridItem>
</Grid>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-grid" style="--_columns:4;--_min:9rem;width:100%" data-mode="fit" data-gap="3">
<div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
<div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Active users</div>
<div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">18,204</div>
</div>
<div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
<div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Conversion</div>
<div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">3.8%</div>
</div>
<div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
<div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Refunds</div>
<div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">12</div>
</div>
<div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
<div style="font-size:var(--eq-text-xs);color:var(--eq-ink-2)">Uptime</div>
<div style="font-size:var(--eq-text-l);font-weight:var(--eq-weight-strong)">99.98%</div>
</div>
<div class="eq-grid-item" style="--_span:3;padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface);min-height:120px" data-span="n">
<div style="font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Signups this week</div>
<div style="height:72px;margin-top:8px;border-radius:var(--eq-radius-s);background:var(--eq-tint)"></div>
</div>
<div class="eq-grid-item" style="padding:12px;border:1px solid var(--eq-line);border-radius:var(--eq-radius-m);background:var(--eq-surface)">
<div style="font-size:var(--eq-text-s);font-weight:var(--eq-weight-strong)">Top plan</div>
<div style="font-size:var(--eq-text-s);color:var(--eq-ink-2)">Team, 61% of seats</div>
</div>
</div>Fixed columns and a full row
Fixed columns use minmax(0, 1fr), so long content wraps inside its cell instead of widening the grid.
Code
import { Grid, GridItem } from "@/components/equip/grid";
const field = { display: "grid", gap: 4, fontSize: "var(--eq-text-s)" };
const box = {
height: 30,
border: "1px solid var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-raised)",
};
export default function Example() {
return (
<Grid columns={2} gap={3} style={{ width: "100%", maxWidth: 480 }}>
<GridItem style={field}>
First name
<div style={box} />
</GridItem>
<GridItem style={field}>
Last name
<div style={box} />
</GridItem>
<GridItem span="full" style={field}>
Billing address
<div style={box} />
</GridItem>
</Grid>
);
}HTMLworks in any framework, with the part's CSS
<div class="eq-grid" style="--_columns:2;width:100%;max-width:480px" data-mode="fixed" data-gap="3">
<div class="eq-grid-item" style="display:grid;gap:4px;font-size:var(--eq-text-s)">First name<div style="height:30px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised)"></div></div>
<div class="eq-grid-item" style="display:grid;gap:4px;font-size:var(--eq-text-s)">Last name<div style="height:30px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised)"></div></div>
<div class="eq-grid-item" style="display:grid;gap:4px;font-size:var(--eq-text-s)" data-span="full">Billing address<div style="height:30px;border:1px solid var(--eq-line-strong);border-radius:var(--eq-radius-m);background:var(--eq-raised)"></div></div>
</div>Props
| Prop | Type | Default | What it does |
|---|---|---|---|
as | "div" | "section" | "ul" | "ol" | "div" | The root element. Use ul or ol with GridItem as="li". |
columns | number | 1 | Fixed equal columns. With min as well, the most columns there will be. |
min | string | none | Auto-fit: as many columns as fit, each at least this wide, e.g. "14rem". Never wider than the grid. |
gap | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 4 | Space between cells, from --eq-space-1 to --eq-space-6. |
rowGap | 0 | 1 | 2 | 3 | 4 | 5 | 6 | none | Space between rows when it should differ from gap. |
align | "start" | "center" | "end" | "stretch" | none | Block alignment of cells within their row. |
...rest | ComponentProps<"div"> | none | Every native attribute of the root element. |
GridItem | as?, span?: number | "full", rowSpan?: number | none | A cell that covers several columns or rows. full runs edge to edge. |
Anatomy
| root | .eq-grid | The grid and the size container its items query. data-mode is fixed or fit. |
|---|---|---|
| item | .eq-grid-item | Only needed for a cell that spans. |
Notes
- Renders on the server with no client JavaScript.
- Cells are placed in DOM order and never reordered, so reading and tab order follow what people see.
- Responsive by its own width rather than the viewport, so the same grid works in a page, a sidebar or a dialog.
- In an auto-fit grid narrower than 32rem a spanning item runs the full width. Wider than that, a span larger than the columns that fit adds an empty track rather than widening the grid; use span="full" for an item that should always run edge to edge.
- Fixed columns are minmax(0, 1fr), so long words wrap inside a cell instead of widening the grid.
- The root is a size container, so give it a width from its parent; it does not grow to fit its content.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/grid.tsx79 lines
import type { ComponentProps, CSSProperties } from "react";
import "./grid.css";
type Space = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type GridProps = ComponentProps<"div"> & {
/** The element at the root. Use ul or ol when the children are li. */
as?: "div" | "section" | "ul" | "ol";
/**
* A fixed number of equal columns. With min as well, it is the most
* columns there will be: the grid drops columns when they would get narrower than min.
*/
columns?: number;
/** Auto-fit: as many columns as fit, each at least this wide. Any CSS length, e.g. "14rem". */
min?: string;
/** Space between cells, read from --eq-space-1 to --eq-space-6. */
gap?: Space;
/** Space between rows when it should differ from gap. */
rowGap?: Space;
/** Block alignment of the cells inside their row. */
align?: "start" | "center" | "end" | "stretch";
};
export function Grid({
as = "div",
columns,
min,
gap = 4,
rowGap,
align,
className,
style,
...rest
}: GridProps) {
// The root changes element but never its attributes, so the div typing holds for all four.
const Root = as as "div";
const vars: Record<string, string | number> = {};
if (columns != null) vars["--_columns"] = columns;
if (min != null) vars["--_min"] = min;
const mode = min != null ? "fit" : "fixed";
return (
<Root
{...rest}
className={["eq-grid", className].filter(Boolean).join(" ")}
style={{ ...(vars as CSSProperties), ...style }}
data-mode={mode}
data-gap={gap}
data-row-gap={rowGap}
data-align={align}
/>
);
}
export type GridItemProps = ComponentProps<"div"> & {
/** The element at the root. li inside a ul or ol grid. */
as?: "div" | "li" | "article" | "section";
/** Columns to cover. "full" runs edge to edge. Falls back to one column when the grid is narrow. */
span?: number | "full";
/** Rows to cover. */
rowSpan?: number;
};
export function GridItem({ as = "div", span, rowSpan, className, style, ...rest }: GridItemProps) {
const Root = as as "div";
const vars: Record<string, string | number> = {};
if (typeof span === "number") vars["--_span"] = span;
if (rowSpan != null) vars["--_row-span"] = rowSpan;
return (
<Root
{...rest}
className={["eq-grid-item", className].filter(Boolean).join(" ")}
style={{ ...(vars as CSSProperties), ...style }}
data-span={span == null ? undefined : span === "full" ? "full" : "n"}
data-row-span={rowSpan == null ? undefined : true}
/>
);
}components/equip/grid.css82 lines
@layer equip {
.eq-grid {
--_gap: var(--eq-space-4);
--_row-gap: var(--_gap);
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(var(--_columns, 1), minmax(0, 1fr));
gap: var(--_row-gap) var(--_gap);
min-width: 0;
margin: 0;
padding: 0;
list-style: none;
/* The grid is the container its items query, so spans can give way when it is narrow. */
container: eq-grid / inline-size;
}
/* Auto-fit: every column at least --_min wide, never wider than the grid itself,
and never more of them than --_columns when that is set. */
.eq-grid[data-mode="fit"] {
grid-template-columns: repeat(
auto-fit,
minmax(
max(
min(var(--_min), 100%),
(100% - (var(--_columns, 64) - 1) * var(--_gap)) / var(--_columns, 64)
),
1fr
)
);
}
.eq-grid[data-gap="0"] { --_gap: 0px; }
.eq-grid[data-gap="1"] { --_gap: var(--eq-space-1); }
.eq-grid[data-gap="2"] { --_gap: var(--eq-space-2); }
.eq-grid[data-gap="3"] { --_gap: var(--eq-space-3); }
.eq-grid[data-gap="4"] { --_gap: var(--eq-space-4); }
.eq-grid[data-gap="5"] { --_gap: var(--eq-space-5); }
.eq-grid[data-gap="6"] { --_gap: var(--eq-space-6); }
.eq-grid[data-row-gap="0"] { --_row-gap: 0px; }
.eq-grid[data-row-gap="1"] { --_row-gap: var(--eq-space-1); }
.eq-grid[data-row-gap="2"] { --_row-gap: var(--eq-space-2); }
.eq-grid[data-row-gap="3"] { --_row-gap: var(--eq-space-3); }
.eq-grid[data-row-gap="4"] { --_row-gap: var(--eq-space-4); }
.eq-grid[data-row-gap="5"] { --_row-gap: var(--eq-space-5); }
.eq-grid[data-row-gap="6"] { --_row-gap: var(--eq-space-6); }
.eq-grid[data-align="start"] { align-items: start; }
.eq-grid[data-align="center"] { align-items: center; }
.eq-grid[data-align="end"] { align-items: end; }
.eq-grid[data-align="stretch"] { align-items: stretch; }
.eq-grid-item {
box-sizing: border-box;
min-width: 0;
}
.eq-grid-item[data-span="n"] {
grid-column: span var(--_span);
}
.eq-grid-item[data-span="full"] {
grid-column: 1 / -1;
}
.eq-grid-item[data-row-span] {
grid-row: span var(--_row-span);
}
/* An auto-fit grid may be down to one or two columns when narrow, and a span would make
columns it does not have, so a spanning item runs edge to edge instead. */
@container eq-grid (width < 32rem) {
.eq-grid[data-mode="fit"] > .eq-grid-item[data-span="n"] {
grid-column: 1 / -1;
}
.eq-grid[data-mode="fit"] > .eq-grid-item[data-row-span] {
grid-row: auto;
}
}
}