MosaicGrid
A grid of tiles the reader can arrange: drag tiles by the header, resize from any edge.
Component behavior
Use MosaicGrid for user-configurable dashboards or workspaces. Store each tile’s position and dimensions to persist the layout.
Tiles accept arbitrary children, including charts, controls, and application content.
It is not a layout primitive. A fixed page that every reader sees the same way should use Grid, which expresses static layout in CSS with no drag affordances and no state to store.
- User-defined layouts persisted across sessions.
- Rearrangeable widgets, charts, and cards.
- The layout must be stored, compared, or supplied as a preset.
Grid: the layout is fixed by the application.
KanbanBoard: dragging changes a record’s workflow stage.
MetricGrid: KPI cards have a fixed layout.
Tabs: content should be divided into separate views.
Props
Pass layout and onLayoutChange for controlled state. Pass defaultLayout for internal state. onLayoutChange fires after a completed gesture, rather than on each animation frame.
| Prop | Type | Default | Description |
|---|---|---|---|
layout | MosaicLayout | — | Controlled layout. Tiles are positioned by the entry whose id matches them. |
defaultLayout | MosaicLayout | [] | Starting layout when the grid manages its own state. |
onLayoutChange | (layout: MosaicLayout) => void | — | Called once per completed gesture with the resulting layout. |
columns | number | 12 | Columns across the full width. Raise it for finer placement; column width is derived from the container. |
rowHeight | number | 64 | Row height, in pixels. Column widths derive from the container width. |
gap | number | 12 | Gutter between cells in px, used for both axes and for the drag maths. |
editable | boolean | false | Turns on drag handles, resize grips, keyboard moves and the cell ruling. Off, it is an ordinary dashboard. |
compact | "vertical" | "none" | "vertical" | Vertical floats tiles up to close gaps. None leaves holes where the reader put them. |
allowOverlap | boolean | false | Skips collision resolution: tiles may stack, and nothing is ever pushed. Implies no compaction. |
swap | boolean | true | Dropping a tile on another trades their places. Same-sized tiles exchange cells; two sharing a clean row swap their order in it and the row re-packs around them. |
preventCollision | boolean | false | Refuses a drag that would land on another tile rather than displacing it, turning the board into fixed slots. |
minRows | number | — | Rows always drawn, so a short or empty board still has somewhere to drop a tile. |
autoPlace | boolean | true | Automatically places tiles absent from the layout using their declared sizes. Each ID is placed once per mount. |
showGrid | boolean | — | Draws the cell ruling. Defaults to on whenever editable is on. |
resizeHandles | MosaicResizeHandle[] | ["e", "w", "s", "se", "sw"] | Enabled resize grips in compass notation. The default includes all eight directions when compact is none or allowOverlap is true. |
onItemDragStart | (id: string) => void | — | Fires when a tile starts moving. |
onItemDragEnd | (id: string, layout: MosaicLayout) => void | — | Fires on drop, with the same layout onLayoutChange receives. |
onItemSwap | (aId: string, bId: string) => void | — | Fires on drop when the drag traded two tiles' places, alongside onItemDragEnd. |
onItemResizeStart | (id: string) => void | — | Fires when a resize begins. |
onItemResizeEnd | (id: string, layout: MosaicLayout) => void | — | Fires when a resize is committed. |
children | ReactNode | — | MosaicTile elements. Anything else renders in normal grid flow and takes no part in dragging. |
Layout model
A layout is a plain array. Each entry places one tile on an integer cell grid: x and w count columns, y and h count rows. There are no pixels in it, so the same layout works at any container width; columns are fractions of whatever space the grid has.
const layout: MosaicLayout = [
{ id: "traffic", x: 0, y: 0, w: 8, h: 2, minW: 3, minH: 2 },
{ id: "errors", x: 8, y: 0, w: 4, h: 2 },
{ id: "hosts", x: 0, y: 2, w: 12, h: 3, static: true },
];Tiles are matched to entries by id. A MosaicTile whose id is not in the layout renders nothing, so removing a tile is a splice from the layout array.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Matches the MosaicTile that renders here. |
x | number | — | Left edge, in columns from 0. |
y | number | — | Top edge, in rows from 0. |
w | number | — | Width in columns. |
h | number | — | Height in rows. |
minW | number | — | Floor for w. A resize stops here rather than going smaller. |
minH | number | — | Floor for h. |
maxW | number | — | Maximum tile width in columns, also limited by the grid column count. |
maxH | number | — | Ceiling for h. |
static | boolean | false | Disables moving and resizing. Other tiles are displaced around this tile. |
Examples
Editable layout
Drag a tile by its header. Resize it from the right edge, the bottom edge, or the corner. Tiles pushed out of the way move down and float back up when there is room; this is the compact behavior, and it keeps gaps out of the board.
Focus a tile grip with Tab. Arrow keys move the tile; Shift+arrow keys resize it. Disabling editing hides the grips and grid guides.
The dragged tile follows the pointer while an outline marks its destination cell. Displaced tiles animate to their new rows. Dragging near a scroll container edge scrolls the container.
Pinning a tile
static: true removes a tile from interaction. It keeps its cell, shows no grips, and never moves when another tile would displace it; a tile dropped on it slides below instead. The pinned tile below is drawn with a dashed border.
A free canvas
compact="none" disables upward compaction. allowOverlap disables displacement and permits tiles to occupy overlapping cells.
Drop behavior
Dropping a tile on another tile attempts a swap. Equal-size tiles exchange cells. Tiles sharing a row can exchange order while preserving gaps and repacking that row.
If a swap is not possible, the drop displaces overlapping tiles downward. Different-size tiles on different rows and static tiles can prevent a swap. Set swap={false} to use displacement for every drop.
preventCollision is the other extreme: a drag that would land on another tile is refused and the tile springs back. Use it for boards of fixed slots, not for boards the reader composes.
// The default. A drop onto a tile trades places with it.
<MosaicGrid editable layout={layout} onLayoutChange={setLayout}>
// Every drop displaces, as a pile of cards would.
<MosaicGrid editable swap={false} …>
// Fixed slots: a drop onto an occupied cell is declined.
<MosaicGrid editable preventCollision …>Tiles that fit their cell
useMosaicTile reports cell dimensions in columns, rows, pixels, and size categories. Use these values for chart sizing or conditional content. MosaicShow conditionally renders children within specified size bounds.
Each tile also publishes data-size, data-w, data-h, data-state and data-swap-target, so a stylesheet can respond to the same thresholds with no JavaScript at all.
Editing a layout in code
Layout operations are exported pure functions. They accept a layout and options and return a new layout without mutating the input. The grid uses the same operations for interactive changes.
const options = { columns: 12, compact: "vertical" } as const;
addLayoutItem(layout, { id: "cost", w: 4, h: 2 }, options); // auto-placed
removeLayoutItem(layout, "cost", options); // closes the gap
moveLayoutItem(layout, "cost", 4, 0, options);
resizeLayoutItem(layout, "cost", { w: 6, h: 3 }, options);
normalizeLayout(fromDatabase, options); // clamp + settle
findFreeSlot(layout, 4, 2, 12); // -> { x, y } — the first hole, reading order
layoutRowCount(layout); // -> rows occupied
getLayoutItem(layout, "cost");
itemsOverlap(a, b);
compactLayout(layout, options);For state around those functions (add, remove, reset, restore), use useMosaicLayout, which wraps them and provides a gridProps object to spread.
Accessibility
Every tile’s move grip is a real button in the tab order. Arrow keys move the tile a cell at a time and Shift + arrows resize it, so the whole board is operable without a pointer. Escape during a pointer drag abandons it and restores the layout the gesture started from.
Keyboard moves announce the resulting cell position or a rejected move through a live region.
The resize grips themselves are pointer-only and aria-hidden: they are decorative targets, and the keyboard path to the same result already runs through the move grip. The cell ruling is decorative too. Tiles that settle into a new row animate, and that animation is skipped under prefers-reduced-motion.
Import
import {
MosaicGrid,
MosaicTile,
useMosaicLayout,
type MosaicLayout,
} from "@solarius/onset";
const [layout, setLayout] = useState<MosaicLayout>([
{ id: "traffic", x: 0, y: 0, w: 8, h: 2 },
{ id: "errors", x: 8, y: 0, w: 4, h: 2 },
]);
<MosaicGrid layout={layout} onLayoutChange={setLayout} editable>
{layout.map((item) => (
<MosaicTile key={item.id} id={item.id} title={item.id}>
…
</MosaicTile>
))}
</MosaicGrid>;