A collection of grid layout components for arranging tiles in
dashboard-style surfaces.
This package exposes two components, each implementing a different
layout model:
DashboardGridis a 2D packed grid: tiles declare explicit
(width, height)spans in column/row units and can span multiple
columns and rows.DashboardLanesis a masonry-style surface aligned with the
emerging WebKit specdisplay: grid-lanes.
Tiles declare a column span only; heights are driven by content;
placement follows a source-ordered, shortest-lane skyline with a
flow-tolerancetiebreaker.
Installation
Install the module:
npm install @wordpress/grid --save
This package assumes that your code will run in an ES2015+ environment.
If you’re using an environment that has limited or no support for such
language features and APIs, you should include the polyfill shipped in
@wordpress/babel-preset-default
in your code.
Setup
Component styles are CSS Modules injected at runtime when a component
mounts; there is no stylesheet to enqueue or import.
Visual defaults (tile gap, elevation, motion, placeholder strokes)
read the design tokens that @wordpress/theme publishes as
--wpds-* CSS custom properties.
Within standard WordPress editor screens
In standard WordPress editor screens (such as the post editor or the
site editor), the design tokens stylesheet is managed centrally by
Gutenberg. You don’t need to add any setup yourself.
Elsewhere
Install and load the design tokens stylesheet in your application:
npm install @wordpress/theme
import '@wordpress/theme/design-tokens.css';
Without the tokens the components stay functional, but gaps,
elevations, and interaction visuals lose their intended values.
Alternatively, define the --wpds-* custom properties the package
consumes yourself.
Choosing a component
| Need | Use |
|---|---|
| Fixed-cell tile dashboard, content fills its cell. | DashboardGrid |
| Masonry / waterfall surface, content drives height. | DashboardLanes |
| Static layout primitive (no per-item state, no drag). | __experimentalGrid from @wordpress/components |
Both components here are higher-level: the user (not the developer)
places and resizes tiles, and the result is emitted through
onChangeLayout. For a static CSS Grid with no spans, drag, or
per-item state, use __experimentalGrid from @wordpress/components.
DashboardGrid
A 2D packed grid where each child has an explicit column and row
span.
Usage
import { DashboardGrid } from '@wordpress/grid';
const layout = [
{ key: 'a', width: 2, height: 2 },
{ key: 'b', width: 4, height: 1 },
{ key: 'c', width: 'fill', height: 1 },
{ key: 'd', width: 'full', height: 1 },
];
function Dashboard() {
const [ current, setCurrent ] = useState( layout );
return (
<DashboardGrid
layout={ current }
columns={ 6 }
editMode
onChangeLayout={ setCurrent }
>
<div key="a">Tile A</div>
<div key="b">Tile B</div>
<div key="c">Tile C</div>
<div key="d">Tile D</div>
</DashboardGrid>
);
}
Each child must have a key prop that matches an entry in the
layout array. Children without a matching layout entry render at
the end of the grid without explicit placement and fall through
CSS Grid’s auto-flow.
Layout model
interface DashboardGridLayoutItem {
key: string; // matches child key
width?: number | 'fill' | 'full'; // column span (see below)
height?: number; // rows to span
order?: number; // lower values render first (responsive mode)
}
width is a discriminated value:
number: span that many columns (clamped to the grid’s column count).'fill': fill the remaining columns in the current row.'full': span every column (grid-column: 1 / -1), forcing a row break.
'fill' is resolved per-row against the remaining free space.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
layout |
DashboardGridLayoutItem[] |
— | Required. Positions and sizes keyed by child key. |
children |
ReactNode |
— | Required. Each child needs a key matching a layout entry. |
columns |
number |
6 |
Total columns (fixed mode). |
minColumnWidth |
number |
— | If set, enables responsive mode: columns derived from container width. Mutually exclusive with columns. |
rowHeight |
number \| 'auto' |
'auto' |
Row height in pixels, or 'auto' to let content size rows. |
editMode |
boolean |
false |
Enables drag-to-reorder and resize handles. |
onChangeLayout |
( layout ) => void |
— | Fired when the user commits a drag or resize. |
onPreviewLayout |
( layout ) => void |
— | Fired continuously during a drag or resize with the in-progress layout. Use for live feedback; onChangeLayout still emits the committed result. |
renderResizeHandle |
ComponentType< ResizeHandleRenderProps > |
— | Override the default corner-triangle resize handle. See Custom resize handle. |
renderDragPreview |
ComponentType< DragPreviewRenderProps > |
— | Wrap the dragged-clone visual mounted inside <DragOverlay>. See Custom drag preview. |
renderGridOverlay |
ComponentType< GridOverlayRenderProps > |
— | Override the default edit-mode overlay that visualizes the column and row tracks. Receives the resolved columns, rows, rowHeight, and isActive. |
className |
string |
— | Extra class on the grid root. |
style |
CSSProperties |
— | Inline styles on the grid root; the grid’s own layout styles win over them. |
DashboardGrid forwards refs to its root <div>, and standard
<div> attributes (id, aria-*, data-*, event handlers,
style, etc.) flow through. The grid’s own layout styles
(gridTemplateColumns, gridAutoRows) override any user-supplied
style for those properties. Tile gap is owned by a design-system
token; override it with --wp-grid-gap (see Theming with CSS
variables).
Child-level props
Children render with the layout entry that matches their key. An
optional prop read off the child lets you keep controls interactive
while edit mode is on:
| Child prop | Type | Description |
|---|---|---|
actionableArea |
ReactNode |
Content rendered above the draggable surface of the grid item. Useful for close buttons, menus, or links that must stay clickable in edit mode. |
Modes
Fixed columns
<DashboardGrid layout={ layout } columns={ 12 }>
{ children }
</DashboardGrid>
Responsive
Columns are computed from container width using minColumnWidth as
the lower bound per column. A ResizeObserver recomputes on
container resize.
<DashboardGrid layout={ layout } minColumnWidth={ 240 }>
{ children }
</DashboardGrid>
In responsive mode, layout items can provide an order to control
display order independently of array position.
Edit mode
When editMode is true:
- Items become draggable (powered by
@dnd-kit). The original tile
stays in place as a dashed placeholder while a clone follows the
cursor through<DragOverlay>. - A resize handle appears on the bottom-right of each item. A
solid outline previews the target size as the cursor moves. - While any tile is dragging or resizing,
actionableAreacontent
on every tile is setinertso hovers on other tiles can’t steal
the gesture. onChangeLayoutfires after drop or resize with the new layout.onPreviewLayoutfires continuously during the interaction for
live feedback; the committed layout is still emitted via
onChangeLayout.- Sibling tiles animate into their new positions when the layout
reflows.
DashboardLanes
A masonry-style surface aligned with display: grid-lanes. Items
declare a column span; heights are driven by content; placement
follows a source-ordered, shortest-lane skyline with a
flow-tolerance tiebreaker.
The layout model and the placement algorithm are described in
Introducing CSS Grid Lanes
on the WebKit blog. This package implements the same model in
JavaScript so it works today on browsers that do not yet support
display: grid-lanes natively; the skyline + tolerance core is
adapted from Simon Willison’s
CSS Grid Lanes Polyfill
(MIT). Once native support lands across browsers, the polyfill can
be removed without any public API change.
Usage
import { DashboardLanes } from '@wordpress/grid';
const layout = [
{ key: 'a' },
{ key: 'hero', width: 2 },
{ key: 'b' },
{ key: 'c' },
];
function Pinboard() {
const [ current, setCurrent ] = useState( layout );
return (
<DashboardLanes
layout={ current }
columns={ 4 }
editMode
onChangeLayout={ setCurrent }
>
<Tile key="a">A</Tile>
<Tile key="hero">Hero (spans 2 lanes)</Tile>
<Tile key="b">B</Tile>
<Tile key="c">C</Tile>
</DashboardLanes>
);
}
Each child must have a key prop that matches an entry in the
layout array. Children without a matching layout entry render at
the end of the surface without explicit placement and fall through
the lanes auto-flow.
Layout model
interface DashboardLanesLayoutItem {
key: string; // matches child key
width?: number; // lanes to span (default 1)
lane?: number; // 0-indexed: pin to a specific lane
order?: number; // lower values render first
}
There is no height field: lanes pack tiles vertically using each
tile’s intrinsic content height.
There is no 'fill': with auto-placement, no item is “left over”
in a row; the algorithm always finds a lane.
'full' (span the entire surface width) is expressed by setting
width to the lane count.
To anchor a tile to a specific column, set lane to its 0-indexed
position. Pinned tiles are placed before auto-placed ones, so auto
items flow around them; out-of-range values (negative, or beyond
columns - width) are clamped.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
layout |
DashboardLanesLayoutItem[] |
— | Required. Span and order keyed by child key. |
children |
ReactNode |
— | Required. Each child needs a key matching a layout entry. |
columns |
number |
6 |
Total lanes (fixed mode). |
minColumnWidth |
number |
— | If set, enables responsive mode: lane count derived from container width. Mutually exclusive with columns. |
flowTolerance |
number |
16 |
Pixel tolerance for source-order tiebreaking when two candidate lanes have similar baselines. Larger values keep tiles closer to reading order at the cost of bigger empty regions. |
rowUnit |
number |
4 |
Snap unit for the polyfill’s grid-row-start math. Smaller values produce sharper placement at the cost of a larger implicit row count. Ignored on browsers with native display: grid-lanes support. |
editMode |
boolean |
false |
Enables drag-to-reorder and horizontal resize. |
onChangeLayout |
( layout ) => void |
— | Fired when the user commits a drag or resize. |
onPreviewLayout |
( layout ) => void |
— | Fired continuously during a drag or resize. |
renderResizeHandle |
ComponentType< ResizeHandleRenderProps > |
— | Override the default side-grip resize handle. See Custom resize handle. |
renderDragPreview |
ComponentType< DragPreviewRenderProps > |
— | Wrap the dragged-clone visual mounted inside <DragOverlay>. See Custom drag preview. |
renderGridOverlay |
ComponentType< GridOverlayRenderProps > |
— | Override the default edit-mode overlay that visualizes the lane tracks. Receives the resolved columns and isActive; lanes pass no row metrics because heights are content-driven. |
className |
string |
— | Extra class on the surface root. |
style |
CSSProperties |
— | Inline styles on the surface root; the surface’s own layout styles win over them. |
Native vs polyfill
DashboardLanes checks CSS.supports( 'display', 'grid-lanes' )
once at mount.
- When supported (Safari 26+, others as the spec ships), the
component emitsdisplay: grid-lanesand the spec’s CSS, and lets
the engine handle layout. The placement layer mounts no per-tile
observers; the onlyResizeObserverleft is the container-width
one used for responsive mode and resize-step math. - When unsupported, an internal hook (
useLanePlacement) measures
each tile’s height with aResizeObserver, runs the source-ordered
shortest-lane algorithm, and emits explicitgrid-column-start
andgrid-row-start/grid-row-end: span Nvalues on each tile.
The same DOM contract is preserved in both paths; the visual is the
same.
Edit mode
Drag-to-reorder works the same as in DashboardGrid. Resize is
horizontal-only: tile heights are content-driven, so there is
no vertical resize gesture. The default handle is a vertical bar
centered on the trailing edge; the cursor is ew-resize.
Sibling tiles animate into their new positions when the layout
reflows.
Shared topics
Performance
onPreviewLayout re-renders the parent on every gesture frame. To
keep the components’ internal children walk from re-running each
frame, memoize the children array when its content is stable:
const tiles = useMemo(
() => layout.map( ( item ) => <Tile key={ item.key }>...</Tile> ),
[ layout ]
);
return (
<DashboardLanes layout={ layout } editMode onPreviewLayout={ ... }>
{ tiles }
</DashboardLanes>
);
Without it the surface still works but walks the children on every
preview update; the overhead is minor up to ~50 tiles and grows
from there. For DashboardLanes, placement runs in a
useLayoutEffect throttled to one frame per measurement burst.
Accessibility
Drag-to-reorder is operable from the keyboard via @dnd-kit‘s
keyboard sensor:
Tabto focus a tile.Spaceto pick it up.- Arrow keys to move it between positions.
Spaceto drop, orEscapeto cancel.
Resize handles are currently pointer-only.
Custom resize handle
Both components accept a renderResizeHandle prop to override the
default visual. The surface owns the resize math (column/row
stepping, throttled delta loop, layout commit) and passes the
gesture wiring (ref, listeners, attributes) as props for the
consumer to spread on the element that should receive pointer
events. The dnd-kit <DndContext> for the resize gesture is
internal to the handle wrapper; consumers do not need to mount
their own.
import { Icon } from '@wordpress/ui';
import { resizeCornerNE } from '@wordpress/icons';
function CustomResizeHandle( {
ref,
listeners,
attributes,
verticalResizable,
} ) {
return (
<div
ref={ ref }
{ ...listeners }
{ ...attributes }
style={ {
position: 'absolute',
bottom: 4,
insetInlineEnd: 4,
cursor: verticalResizable ? 'nwse-resize' : 'ew-resize',
} }
>
<Icon icon={ resizeCornerNE } size={ 16 } />
</div>
);
}
<DashboardGrid
layout={ layout }
editMode
renderResizeHandle={ CustomResizeHandle }
>
{ tiles }
</DashboardGrid>;
The component receives:
| Prop | Type | Description |
|---|---|---|
ref |
( node ) => void |
dnd-kit ref; assign on the gesture-bearing element. |
listeners |
SyntheticListenerMap \| undefined |
Pointer/keyboard listeners; spread on the same element. |
attributes |
DraggableAttributes |
Accessibility/dnd-kit attributes; spread alongside listeners. |
verticalResizable |
boolean |
False on DashboardLanes and on DashboardGrid with rowHeight: 'auto'. Useful for adapting cursor or visual cue. |
isResizing |
boolean |
True while the user is actively dragging this handle. Use it to swap colors, icons, or transforms during the gesture. |
itemId |
string |
Owning tile’s key. |
The handle is only mounted while the surface is in edit mode
(editMode={ true }), so the custom component never has to
short-circuit on a disabled state.
Custom drag preview
While a tile is being dragged, dnd-kit clones it into a <DragOverlay>
that follows the cursor. Both surfaces wrap that clone with a thin
functional frame (scale, cursor: grabbing, pointer-events:) that advertises the lift, but they do not impose visual
none
chrome on top: any styles the consumer applied to the tile children
carry through to the dragged clone unchanged.
When the dragged state should look structurally different from the
persistent tile (a stronger shadow, a different border, an extra
badge…), pass a renderDragPreview component. The surface mounts
it inside the functional frame and supplies the cloned children
plus the active tile’s key:
import { DashboardGrid } from '@wordpress/grid';
import type { DragPreviewRenderProps } from '@wordpress/grid';
function DragPreview( { children }: DragPreviewRenderProps ) {
return (
<div className="my-tile-while-dragging">
{ children }
</div>
);
}
<DashboardGrid
layout={ layout }
editMode
renderDragPreview={ DragPreview }
>
{ tiles }
</DashboardGrid>;
The component receives:
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
The cloned tile content the surface mounts inside <DragOverlay>. Place it where the visual wrapper expects the tile body. |
itemId |
string |
Owning tile’s key. Useful when chrome varies by tile. |
For token-only tweaks (lift scale, placeholder opacity, outline
color, placeholder radius), prefer the CSS variables
below; reach for renderDragPreview only when the dragged state
needs markup the persistent tile does not have.
Theming with CSS variables
Both surfaces expose a small set of CSS custom properties for
visuals that need to flex between consumers without writing a render
prop. Override them on any ancestor of the surface root (or on the
root itself via style). All values fall back to sensible defaults.
| Variable | Default | Applies to |
|---|---|---|
--wp-grid-gap |
var(--wpds-dimension-gap-xl) |
Gap between tiles on DashboardGrid, DashboardLanes, and the edit overlay. |
--wp-grid-drag-preview-scale |
1.05 |
Lift scale of the drag-preview functional frame. Set to 1 to disable the lift. |
--wp-grid-drag-preview-radius |
0 |
Border radius of the drag-preview functional frame so the lift shadow follows the consumer’s tile shape. |
--wp-grid-placeholder-opacity |
0.4 |
Opacity of the placeholder tile (the original item while a drag is in flight). |
--wp-grid-placeholder-outline-style |
dashed |
Outline style of the drag placeholder (for example solid or dotted). |
--wp-grid-resize-preview-outline-style |
solid |
Border style of the resize-preview overlay (for example dashed or dotted). |
--wp-grid-placeholder-outline-color |
var(--wpds-color-stroke-interactive-brand) |
Outline color of the placeholder and of the resize-preview overlay. |
--wp-grid-placeholder-radius |
0 |
Border radius of the placeholder, used to match the consumer’s tile shape so the outline traces the right silhouette. |
--wp-grid-overlay-tile-bg |
var(--wpds-color-background-surface-neutral-weak) |
Background of the marker tiles painted by the default edit-mode overlay. |
Contributing to this package
This is an individual package that’s part of the Gutenberg project.
The project is organized as a monorepo. It’s made up of multiple
self-contained software packages, each with a specific purpose. The
packages in this monorepo are published to npm
and used by WordPress as well as
other software projects.
To find out more about contributing to this package or Gutenberg as a
whole, please read the project’s main
contributor guide.