Grid
Divides the available width into up to 12 equal tracks that respond to the grid’s own width, not the viewport. Use it for card layouts and forms; wrap it in Container for a centered width cap.
Code
import {Card, Grid, Stack} from '@earendil-works/elanor-react';
import {useState} from 'react';
import styles from './grid.module.css';
const sections = ['Overview', 'Usage', 'Activity', 'Members', 'Settings', 'Billing'];
export default function GridDemo() {
const [width, setWidth] = useState(100);
return (
<Stack gap="large" align="stretch">
<label>
Grid width: {width}%{' '}
<input
type="range"
min={20}
max={100}
value={width}
onChange={(event) => setWidth(event.currentTarget.valueAsNumber)}
/>
</label>
<div style={{inlineSize: `${width}%`}}>
<Grid.Root>
{sections.map((section) => (
<Grid.Item
key={section}
span={{base: 'full', xSmall: 6, small: 4}}
render={<Card className={styles.cell} />}
>
{section}
</Grid.Item>
))}
</Grid.Root>
</div>
</Stack>
);
} /* Shows the cell boundaries in these demos. */
.cell {
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
}
/* Marks each subgrid row as one element in this demo. */
.row {
padding-block: var(--semantic-dimension-space-padding-small);
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
} Usage
import {Card, Grid} from '@earendil-works/elanor-react';
<Grid.Root>
<Grid.Item span={{base: 'full', small: 6, medium: 4}} render={<Card />}>
Overview
</Grid.Item>
<Grid.Item>Full row</Grid.Item>
</Grid.Root>;
Items span a full row unless span says otherwise. render lets another component, such as Card, be the item without an extra wrapper.
Breakpoints
span and start keys are container breakpoints. They query the nearest Grid.Root, including from inside a subgrid.
| Key | Minimum grid width |
|---|---|
base |
Always |
xSmall |
40rem |
small |
48rem |
medium |
64rem |
large |
80rem |
xLarge |
96rem |
A capped Container leaves 76rem for content, so large and xLarge apply only when the cap is removed.
Start
start places an item on a track, or with end, at the end of its row. Key it by breakpoint like span. Below, the filter takes a full row in narrow grids and ends the heading’s row from xSmall on.
Usage
Code
import {Dropdown, Grid, Heading} from '@earendil-works/elanor-react';
const ranges = [
{value: 'week', label: 'Last 7 days'},
{value: 'month', label: 'Last 30 days'},
{value: 'quarter', label: 'Last 90 days'},
];
export default function GridStart() {
return (
<Grid.Root>
<Grid.Item span={{base: 'full', xSmall: 6}}>
<Heading level={3} size={4}>
Usage
</Heading>
</Grid.Item>
<Grid.Item span={{base: 'full', xSmall: 4}} start={{xSmall: 'end'}}>
<Dropdown.Root items={ranges} defaultValue="month">
<Dropdown.Trigger density="compact">
<Dropdown.Prefix>Date range</Dropdown.Prefix>
<Dropdown.Value />
<Dropdown.Icon />
</Dropdown.Trigger>
<Dropdown.Portal>
<Dropdown.Positioner>
<Dropdown.Popup>
<Dropdown.List>
{ranges.map((range) => (
<Dropdown.Item key={range.value} value={range.value}>
<Dropdown.ItemIndicator />
<Dropdown.ItemText>{range.label}</Dropdown.ItemText>
</Dropdown.Item>
))}
</Dropdown.List>
</Dropdown.Popup>
</Dropdown.Positioner>
</Dropdown.Portal>
</Dropdown.Root>
</Grid.Item>
</Grid.Root>
);
} An item never moves before the previous one; if it can’t start after it, it begins a new row. Screen readers and keyboard focus follow source order.
Columns
Spans larger than columns are clamped to the column count. Starts move back until the item fits.
Code
import {Card, Grid, Stack, type GridColumns} from '@earendil-works/elanor-react';
import {useState} from 'react';
import styles from './grid.module.css';
export default function GridColumnsExample() {
const [columns, setColumns] = useState<GridColumns>(4);
return (
<Stack gap="large" align="stretch">
<label>
Columns{' '}
<select
value={columns}
onChange={(event) => setColumns(Number(event.currentTarget.value) as GridColumns)}
>
{Array.from({length: 12}, (_, index) => (
<option key={index}>{index + 1}</option>
))}
</select>
</label>
<Grid.Root columns={columns}>
{Array.from({length: columns}, (_, index) => (
<Grid.Item key={index} span={1} render={<Card className={styles.cell} />}>
{index + 1}
</Grid.Item>
))}
<Grid.Item render={<Card className={styles.cell} />}>No span: a full row</Grid.Item>
<Grid.Item span={12} render={<Card className={styles.cell} />}>
span=12: clamped to the column count
</Grid.Item>
</Grid.Root>
</Stack>
);
} /* Shows the cell boundaries in these demos. */
.cell {
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
}
/* Marks each subgrid row as one element in this demo. */
.row {
padding-block: var(--semantic-dimension-space-padding-small);
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
} Subgrid
Use Grid.Subgrid when one element, such as a list row or a card, must place its children on the parent’s columns. Below, each project row is one element with its own background, and its cells line up with the header. Subgrid shares columns only; rows stay independent.
Code
import {Grid} from '@earendil-works/elanor-react';
import styles from './grid.module.css';
const projects = [
{name: 'Website redesign', status: 'In review', owner: 'Alex Morgan'},
{name: 'Billing migration', status: 'Draft', owner: 'Sam Lee'},
];
export default function GridSubgridExample() {
return (
<Grid.Root columns={4}>
<Grid.Item span={2}>Project</Grid.Item>
<Grid.Item span={1}>Status</Grid.Item>
<Grid.Item span={1}>Owner</Grid.Item>
{projects.map((project) => (
// Each row is one element with its own background; its cells still use the root's columns.
<Grid.Subgrid key={project.name} className={styles.row}>
<Grid.Item span={2}>{project.name}</Grid.Item>
<Grid.Item span={1}>{project.status}</Grid.Item>
<Grid.Item span={1}>{project.owner}</Grid.Item>
</Grid.Subgrid>
))}
</Grid.Root>
);
} /* Shows the cell boundaries in these demos. */
.cell {
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
}
/* Marks each subgrid row as one element in this demo. */
.row {
padding-block: var(--semantic-dimension-space-padding-small);
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
} Nested grids
A Grid.Root inside an item starts a new grid: it has its own column count and measures its own width.
Code
import {Card, Grid} from '@earendil-works/elanor-react';
import styles from './grid.module.css';
export default function GridNested() {
return (
<Grid.Root>
{([4, 8] as const).map((span) => (
<Grid.Item key={span} span={{base: 'full', small: span}}>
<Grid.Root columns={2} gap="small">
<Grid.Item render={<Card className={styles.cell} />}>Nested in {span} tracks</Grid.Item>
<Grid.Item span={{base: 'full', xSmall: 1}} render={<Card className={styles.cell} />}>
First
</Grid.Item>
<Grid.Item span={{base: 'full', xSmall: 1}} render={<Card className={styles.cell} />}>
Second
</Grid.Item>
</Grid.Root>
</Grid.Item>
))}
</Grid.Root>
);
} /* Shows the cell boundaries in these demos. */
.cell {
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
}
/* Marks each subgrid row as one element in this demo. */
.row {
padding-block: var(--semantic-dimension-space-padding-small);
border-radius: var(--semantic-dimension-radius-small);
background: var(--semantic-color-background-fill-secondary);
} Placement rules
- Items and subgrids must be direct DOM children of their grid. Fragments are fine; wrapper elements are not.
- Other children use native CSS Grid placement, without the full-row default or clamping.
- Clamping reads
columns,span, andstart. If you setgrid-template-columnsorgrid-columnin CSS, handle all placement in CSS. - Grid keeps source order, including with
start.
Changing breakpoints
Container queries can’t use CSS variables, so src/grid/grid.module.css writes thresholds as values annotated with their tokens:
@container elanor-grid (48rem /* token: semantic.dimension.breakpoint.small */ <= width) {
/* Responsive rules */
}
After changing breakpoint tokens, update the annotated values in the Grid and Layout CSS:
pnpm --filter @earendil-works/elanor-react generate:css
check and build fail on stale values.
API
Grid.Root
| Prop | Type | Default | Description |
|---|---|---|---|
columns | 1 | … | 12 | 12 | Number of equal tracks. |
gap | 'xxSmall' | 'xSmall' | 'small' | 'medium' | 'large' | 'xLarge' | 'medium' | Space between tracks. |
Renders <div>. Also accepts its HTML attributes, ref, and render.
Grid.Item
| Prop | Type | Default | Description |
|---|---|---|---|
span | 1 | … | 12 | 'full' | Partial<Record<GridBreakpoint, GridSpan>> | 'full' | Tracks to span, or full for a full row. Key by container breakpoint, such as {base: 'full', small: 6}; each value applies until the next key. |
start | 1 | … | 12 | 'end' | 'auto' | Partial<Record<GridBreakpoint, GridStart>> | 'auto' | Track to start on, or end for the row's end. Keyed like span; full rows ignore it. |
Renders <div>. Also accepts its HTML attributes, ref, and render.
Grid.Subgrid
| Prop | Type | Default | Description |
|---|---|---|---|
span | 1 | … | 12 | 'full' | Partial<Record<GridBreakpoint, GridSpan>> | 'full' | Tracks to span, or full for a full row. Key by container breakpoint, such as {base: 'full', small: 6}; each value applies until the next key. |
start | 1 | … | 12 | 'end' | 'auto' | Partial<Record<GridBreakpoint, GridStart>> | 'auto' | Track to start on, or end for the row's end. Keyed like span; full rows ignore it. |
Renders <div>. Also accepts its HTML attributes, ref, and render.