Skip to content

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.

Overview
Usage
Activity
Members
Settings
Billing
Code
demo.tsx
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>
  );
}
grid.module.css
/* 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
start.tsx
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.

1
2
3
4
No span: a full row
span=12: clamped to the column count
Code
columns.tsx
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>
  );
}
grid.module.css
/* 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.

Project
Status
Owner
Website redesign
In review
Alex Morgan
Billing migration
Draft
Sam Lee
Code
subgrid.tsx
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>
  );
}
grid.module.css
/* 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.

Nested in 4 tracks
First
Second
Nested in 8 tracks
First
Second
Code
nested.tsx
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>
  );
}
grid.module.css
/* 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, and start. If you set grid-template-columns or grid-column in 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

PropTypeDefaultDescription
columns1 | … | 1212Number 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

PropTypeDefaultDescription
span1 | … | 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.
start1 | … | 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

PropTypeDefaultDescription
span1 | … | 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.
start1 | … | 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.