# react-mosaic
> React tiling window manager (v7): an immutable n-ary tree of resizable, drag-and-drop panels, splits and tab groups.
Coding agents: the npm package ships an Agent Skill with the v7 API and common mistakes, at
`node_modules/react-mosaic-component/skills/react-mosaic/SKILL.md`
(or `npx skills add nomcopter/react-mosaic`).
v7 trees use `{ type: "split", direction, children, splitPercentages }` and numeric paths;
the v6 `first`/`second` shape is legacy.
The generated API reference is in [llms-api.txt](llms-api.txt).
## Getting started
## react-mosaic
**react-mosaic** is a React tiling window manager. It gives you a drag-to-resize,
drag-to-rearrange layout of panels that users can freely rearrange, inspired by
IDE window management and i3-style tiling.
### Install
```bash
npm install react-mosaic-component react react-dom
```
You'll also want to import the compiled stylesheet in your app's entry point:
```tsx
import 'react-mosaic-component/react-mosaic-component.css';
```
### Quick start
The smallest useful example:
```tsx
import { Mosaic, MosaicWindow } from 'react-mosaic-component';
import 'react-mosaic-component/react-mosaic-component.css';
export function App() {
return (
);
}
```
To render it, mount `App` from your entry file as usual:
```tsx
// main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
,
);
```
The wrapper `div` needs a real height; `Mosaic` fills its parent, so a parent
with no height gives you an empty page.
That's three panels' worth of functionality in 15 lines: two tiles side by side,
a draggable divider between them, drag handles on each window title bar to
rearrange, and a default toolbar with split/remove buttons.
### Where to go next
- **[Tree structure](./concepts/tree-structure)** — how layouts are modeled as
n-ary trees of split, tab, and leaf nodes, with a live example.
- **[Custom toolbars](./guides/custom-toolbar)** — replace the default window
controls with your own, editable inline.
- **[Demo](/demo)** — the full kitchen-sink demo app showing themes, editable
tab titles, and programmatic layout actions.
- **API reference** — every public export, generated from source.
### Browser support
Current versions of Chrome, Edge, Firefox and Safari, on desktop and mobile
(touch drag and drop is built in). Internet Explorer is not supported. The
package is published as modern JavaScript, so if you target older browsers,
let your bundler transpile `react-mosaic-component` too.
### Key features
- **N-ary tree layouts.** A single split can hold any number of children, not
just two.
- **Tabs as first-class citizens.** Tab containers are a node type, not a
bolted-on convention.
- **Controlled or uncontrolled.** Pass `value` + `onChange` to manage the tree
in your own state, or `initialValue` to let the component own it.
- **Drag-and-drop.** Built on `react-dnd` with HTML5 and touch backends.
- **Theming.** Works with or without Blueprint; ships a default CSS theme plus
CSS variables you can override.
- **Zero-config migration.** Legacy v6 binary trees are converted
automatically at render time, and [`convertLegacyToNary`](./migration/from-v6)
is available for explicit upgrades.
---
## Controlled vs uncontrolled
`Mosaic` supports both controlled and uncontrolled patterns, mirroring the way
`` works in React. Which one you pick determines who owns the layout
state.
### Uncontrolled — `initialValue`
Pass `initialValue` and let the component manage the tree internally. This is
the shortest path to a working layout:
```tsx live
function UncontrolledExample() {
return (
);
}
```
Use this when you don't need to read the tree from outside the component —
the user rearranges panels and you never touch the state.
### Controlled — `value` + `onChange`
Pass `value` and `onChange` to own the state yourself. This is required any
time you need to:
- **persist the layout** (localStorage, server, URL);
- **programmatically mutate it** (add a panel from a button outside the
mosaic, reset to a preset);
- **react to changes** (analytics, undo/redo, derived UI).
```tsx
import { useState } from 'react';
import { Mosaic, MosaicWindow, MosaicNode } from 'react-mosaic-component';
function ControlledExample() {
const [tree, setTree] = useState | null>({
type: 'split',
direction: 'row',
children: ['a', 'b'],
});
return (
value={tree}
onChange={setTree}
renderTile={(id, path) => (
{id}
)}
/>
);
}
```
The `onChange` callback fires for every mutation — drag-to-resize,
drag-to-rearrange, button clicks, tab switches. `value` can be `null` to
represent an empty layout (the `zeroStateView` is rendered in that case).
It also gets a second, optional `meta` argument that says what caused the
change, for example `{ type: 'remove', path, node }` or
`{ type: 'resize', path, splitPercentages }`. `onRelease` receives the same
`meta`. See `MosaicChangeMeta` in the API reference for every change type, and
[Know which panel was removed](../guides/recipes#know-which-panel-was-removed)
for an example.
### Mixing the two: `onRelease`
`onChange` fires on every frame of an ongoing drag. If you're persisting to
disk or hitting an API, throttle that — or better, use `onRelease` to only
save when the user finishes the interaction:
```tsx
savePreference('layout', finalTree)}
renderTile={/* ... */}
/>
```
`onRelease` fires once, with the final tree, after the user releases the
drag.
### Rules of thumb
- **Throwaway / demo / docs embed** → `initialValue`. Nothing external needs
to see the tree.
- **Anything you persist or manipulate from outside** → controlled `value` +
`onChange`. Throttle or debounce the write path with `onRelease`.
- **Don't mix them.** Pass `initialValue` *or* `value`, not both — the
component will warn you at runtime.
---
## Tabs
Tab containers are a first-class node type in react-mosaic. They are not a
convention built on top of splits — they have their own shape in the tree,
their own drop targets, and their own close semantics.
### The node shape
```ts
interface MosaicTabsNode {
type: 'tabs';
tabs: T[];
activeTabIndex: number;
}
```
A tab node is a container that displays any number of leaf keys as a tab
strip. The active tab is rendered as a full panel below the tabs; clicking a
tab header switches `activeTabIndex`.
Tab nodes can appear anywhere a leaf node can — including inside a split,
inside another tab's panels? No: tabs can't nest directly. If a leaf is
itself a complex layout, model it as a leaf that renders its own mosaic, not
as a tab containing a tab.
### A live tabbed layout
```tsx live
function TabsExample() {
return (
);
}
```
Drag a tab header to split it out into its own panel. Drag a non-tab panel
onto a tab strip to add it to the group.
### Close semantics: `canClose`
`Mosaic`'s `canClose` prop decides, per tab, whether the close button is
rendered, visible-but-disabled, or fully enabled. Return one of three
strings:
- `'canClose'` — close button is enabled.
- `'cannotClose'` — close button is visible but disabled (hover tooltip
typically explains why).
- `'noClose'` — close button is not rendered at all.
```tsx
const canClose: TabCanCloseFunction = (tabKey, tabs) => {
if (tabKey === 'home') return 'noClose'; // pinned, no X at all
if (tabKey === 'readme') return 'cannotClose'; // protected, X disabled
if (tabs.length <= 1) return 'cannotClose'; // don't allow empty group
return 'canClose';
};
```
The three states let you model pinned tabs, protected tabs, and the normal
case without building your own custom toolbar.
### Editable tab titles
`renderTabTitle` lets you replace the default label with any React node —
including an inline editor. The demo at [/demo](/demo) uses this to let
users double-click a tab header and rename it in place. The prop receives
`{ tabKey, isActive }`, so you can swap out the render based on which tab is
focused.
### Tab-group toolbars
Tab nodes have their own toolbar, configured via `renderTabToolbar`.
Tab-specific button variants live in the library: `AddTabButton`,
`TabSplitButton`, `TabExpandButton`, `TabRemoveButton`. See the [custom
toolbars guide](../guides/custom-toolbar#toolbars-inside-tab-groups) for the
full shape.
---
## Tree structure
Every layout in react-mosaic is a `MosaicNode` — a tree of three kinds of
node:
1. **Leaf nodes** — a single panel, represented by its key of type `T`.
2. **Split nodes** — `{ type: 'split', direction, children, splitPercentages? }`.
Holds any number of child nodes laid out horizontally (`'row'`) or vertically
(`'column'`).
3. **Tab nodes** — `{ type: 'tabs', tabs, activeTabIndex }`. Holds any number of
leaf keys and displays them as a tab group.
The type signature is:
```ts
type MosaicNode =
| MosaicSplitNode
| MosaicTabsNode
| T;
interface MosaicSplitNode {
type: 'split';
direction: 'row' | 'column';
children: MosaicNode[];
/** Array summing to 100. Omit for equal distribution. */
splitPercentages?: number[];
}
interface MosaicTabsNode {
type: 'tabs';
tabs: T[];
activeTabIndex: number;
}
```
### Try it
The code block below is live — edit any value and the preview above it updates
immediately. Change `direction`, add a third child to `children`, adjust
`splitPercentages`:
```tsx live
function TreeStructureExample() {
return (
);
}
```
### Paths
A `MosaicPath` is the route to a node through the tree, expressed as an array
of numeric indices:
- `[]` — the root
- `[0]` — first child of the root
- `[1, 2]` — third child of the second child of the root
Paths are how every tree-manipulation utility identifies which node to operate
on. The `path` argument you receive in `renderTile` and `MosaicWindow` is the
path to _that panel's_ current location — you don't track it yourself, the
component gives it to you.
### Leaf keys
Panel identifiers (`T`) must be serialisable and unique within the tree.
`string` and `number` are both fine. The library does not care what the key
means — it's your identifier for whatever that panel represents (a file, a
report, a view mode, a tab inside your own model).
Leaf keys appear:
- In the tree as bare values (`children: ['a', 'b']`).
- As the first argument to your `renderTile` function.
- As the first argument to `TabTitleRenderer`, `TabButtonRenderer`, etc.
### Why n-ary?
Before v7, split nodes were binary: every split had `first` and `second`
children. Adding a third panel meant nesting another split inside, which made
the tree deep and the indices shift every time you added a panel.
N-ary splits let a single split hold three, five, ten children with one
`splitPercentages` array. The "auto arrange" action in the [demo](/demo)
demonstrates the difference directly — notice the tree stays shallow.
If you have a legacy binary tree in storage, you don't need to migrate it
manually: `` converts on the fly, and
[`convertLegacyToNary`](../migration/from-v6) is available for explicit
upgrades.
---
## Updates and mutations
The tree is immutable. Every mutation — drag-to-resize, dropping a panel,
clicking the remove button — produces a brand-new tree and hands it to
`onChange`. The library never mutates the value you pass in.
That immutability is made practical by a small set of helpers that take a
tree and a path, and return a new tree.
### `updateTree` and `MosaicUpdate`
Everything funnels through `updateTree`:
```ts
import { updateTree, MosaicUpdate } from 'react-mosaic-component';
const updates: MosaicUpdate[] = [
// flip the root split from row to column
{ path: [], spec: { direction: { $set: 'column' } } },
];
const next = updateTree(tree, updates);
```
A `MosaicUpdate` is a `{ path, spec }` pair. The spec is an
[`immutability-helper`](https://github.com/kolodny/immutability-helper)
command — `$set`, `$push`, `$apply`, etc. — applied at the node addressed
by `path`.
You rarely write specs by hand. The library ships a handful of factories
that produce the common ones for you.
### The update factories
| Factory | What it does |
| --- | --- |
| `createRemoveUpdate(tree, path)` | Removes the node at `path`, collapsing its parent if needed. |
| `createHideUpdate(tree, path)` | Temporarily hides the node at `path` (sets its split percentage to 0). |
| `createExpandUpdate(path, percentage)` | Grows the node at `path` to take `percentage` of its parent, at every level up to the root. |
| `createDragToUpdates(tree, sourcePath, destinationPath, dropInfo)` | Produces the full update sequence for a drag-and-drop operation. `dropInfo` is e.g. `{ type: 'split', position: 'left' }`. |
`createDragToUpdates` returns an array of updates; the others return a single
`MosaicUpdate`. Put them together in one array and pass it to
`updateTree`:
```ts
const next = updateTree(tree, [createRemoveUpdate(tree, [1, 0])]);
```
### Example: a "reset layout" button
```tsx
import { useState } from 'react';
import {
Mosaic,
MosaicWindow,
MosaicNode,
createBalancedTreeFromLeaves,
getLeaves,
} from 'react-mosaic-component';
function App() {
const [tree, setTree] = useState | null>(INITIAL);
const rebalance = () => {
setTree((current) => createBalancedTreeFromLeaves(getLeaves(current)));
};
return (
<>
>
);
}
```
`getLeaves` walks the tree and returns the leaf keys in order;
`createBalancedTreeFromLeaves` packs them back into a minimal-depth n-ary
tree. Chaining the two gives you "reshuffle into a clean grid" in one line.
### Why not just mutate?
Immutability keeps rendering predictable: React re-renders only when the
reference changes, and because `Mosaic` is happy to be driven by `value`,
you get free time-travel. Keep a stack of past trees and you have undo for
the price of one array.
```tsx
const [history, setHistory] = useState[]>([INITIAL]);
const tree = history[history.length - 1];
const push = (next: MosaicNode) =>
setHistory((h) => [...h, next]);
const undo = () =>
setHistory((h) => (h.length > 1 ? h.slice(0, -1) : h));
```
Because mosaic hands you a whole new tree on every `onChange`, this pattern
is as simple as it looks.
---
## Custom toolbars
Every `MosaicWindow` renders a title bar with a default set of controls on the
right. You can replace that set entirely, add to it, or style it — all by
passing React nodes to the `toolbarControls` prop.
### The default toolbar
Out of the box you get split, expand and remove buttons. The presets are
exported so you can reuse them:
```tsx
import {
DEFAULT_CONTROLS_WITH_CREATION,
DEFAULT_CONTROLS_WITHOUT_CREATION,
} from 'react-mosaic-component';
```
- `DEFAULT_CONTROLS_WITH_CREATION` — Split, Expand, Remove
- `DEFAULT_CONTROLS_WITHOUT_CREATION` — Expand, Remove (no Split)
Passing `toolbarControls={DEFAULT_CONTROLS_WITHOUT_CREATION}` disables the
split button without forcing you to rebuild the toolbar.
### Editable example — change a button color live
Edit the `buttonColor` constant below and watch the toolbar update. This is
the entire value of live-coding docs: the example _is_ the API surface.
```tsx live
function CustomToolbarExample() {
const buttonColor = '#106ba3'; // try '#db3737', '#0f9960', '#d9822b'
const toolbar = (
);
}
```
### Building your own buttons
Each default button is a thin wrapper around `DefaultToolbarButton`, which
handles the icon+label+click plumbing. It takes `title`, `className`,
`onClick` and an optional `text` label; it doesn't render `children`, so put
the label (or an emoji) in `text`, or style an icon through `className`. You
can compose your own:
```tsx live
function CustomButtonExample() {
function StarButton() {
return (
alert('starred!')}
/>
);
}
const toolbar = (
);
}
```
### Accessing window actions from a custom button
Custom buttons often need to operate on the panel they live in — remove it,
expand it, replace its content. `MosaicWindowContext` exposes those actions:
```tsx
import { useContext } from 'react';
import {
MosaicWindowContext,
DefaultToolbarButton,
} from 'react-mosaic-component';
function DuplicateButton() {
const { mosaicWindowActions } = useContext(MosaicWindowContext);
return (
mosaicWindowActions.split()}
/>
);
}
```
Similarly, `MosaicContext` gives you tree-level actions (`hide`, `expand`,
`remove`, `replaceWith`, `updateTree`) for operations that aren't scoped to a
single window.
### Additional controls drawer
`additionalControls` adds a "More" button to the title bar that opens a drawer
under it. While the drawer is open, an overlay covers the window body and
closes the drawer on click. If you'd rather keep the body interactive, turn
the overlay off:
```tsx
}
disableAdditionalControlsOverlay
onAdditionalControlsToggle={(open) => console.log('drawer open:', open)}
>
...
```
To open or close the drawer from your own code, call
`mosaicWindowActions.setAdditionalControlsOpen(true | false | 'toggle')` from
`MosaicWindowContext`.
### Toolbars inside tab groups
Tab groups render their own toolbar: tab buttons on the left, then on the
right a library-owned drag handle followed by a controls cluster (add tab,
split, remove by default). You can reshape the controls cluster without
giving up drag-and-drop — that stays with the library.
The customization props, ordered from least to most invasive:
| Prop | What it swaps | Library keeps owning |
|---|---|---|
| `renderTabTitle` | Content inside each tab button | Drag, close, DnD |
| `renderTabToolbarControls` | The right-side controls cluster (add, split, remove, …) | Drag handle, drop targets |
| `renderTabToolbar` | The entire tab bar (escape hatch) | Nothing — you re-wire DnD yourself |
Prefer the first two. `renderTabToolbar` is a last resort; opting into it
means re-implementing tab rendering, drop targets, and drag handles.
#### Composing your own controls cluster
`renderTabToolbarControls` receives `{ tabs, activeTabIndex, path, mosaicId }`
and returns a `ReactNode`. You decide which buttons are present, in what
order, and when. The library injects its drag handle as a sibling before
your controls, so you never touch `react-dnd`.
The tab-specific buttons are exported so you can drop them in directly:
```tsx
import {
DefaultAddTabButton,
TabSplitButton,
TabRemoveButton,
TabExpandButton,
} from 'react-mosaic-component';
```
#### Per-tab controls
Show buttons that depend on which tab is active — e.g. a preview action
that's only meaningful for Markdown files.
```tsx live
function PerTabControlsExample() {
const isMarkdown = (id) => typeof id === 'string' && id.endsWith('.md');
return (
);
}
```
Switch tabs: the preview button appears only for `.md` files. The drag
handle between the controls and the tab row is still there — you never had
to think about it.
#### Capping the number of tabs
Omit `DefaultAddTabButton` when the tab group is full. Because you compose
the cluster yourself, conditional rendering is just a React expression.
```tsx live
function TabLimitExample() {
const MAX_TABS = 4;
let counter = 0;
return (
);
}
```
Add tabs until you reach four — the `+` disappears.
#### Fully custom add button
Swap `DefaultAddTabButton` for your own element. Call
`mosaicActions.addTab(path)` to run the library's tab-add logic: it
appends to an existing tab group, or converts a leaf into a 2-tab group
when `path` points at a leaf. Returns a promise that rejects if
`createNode` isn't set.
```tsx live
function CustomAddButtonExample() {
let counter = 0;
function CustomAddButton({ path }) {
const { mosaicActions } = React.useContext(MosaicContext);
return (
{
if (window.confirm('Open a new tab?')) {
mosaicActions.addTab(path);
}
}}
/>
);
}
return (
);
}
```
The same `mosaicActions.addTab(path)` is available anywhere in your app —
from keyboard shortcut handlers, menu items, command palettes — not just
from inside the renderer.
#### Escape hatch: `renderTabToolbar`
If none of the slots above fit, `renderTabToolbar` hands you the entire tab
bar. You receive `{ tabs, activeTabIndex, path, DraggableTab }` and must
return the full toolbar element, including tab rendering, drop targets, and
anything else. This is rarely what you want; reach for it only when the
default layout itself is wrong for your app (e.g. vertical tabs, tabs on the
bottom).
---
## Drag and drop integration
Mosaic uses [react-dnd](https://react-dnd.github.io/react-dnd/) with a
multi-backend (HTML5 on desktop, touch on mobile). Most apps never need to
think about it. This page is for when your app has its own drag and drop, or
when you want to drag things from outside into the layout.
### How `Mosaic` sets up react-dnd
`` wraps itself in a `DndProvider` created with `context={window}`.
That gives one drag and drop manager per page, shared by every `Mosaic`
instance, and it survives remounts from route changes, hot reload and React
`StrictMode`. You don't need to do anything to render several mosaics on one
page.
### Touch screens
The multi-backend switches to touch drag and drop on the first touch, so
everything works with a finger too:
- **A touch becomes a drag after the finger moves 10px**, so a tap on a
toolbar button stays a tap even if the finger wobbles a little.
- **Windows** drag by their title bar, and dividers resize, right away. Title
bars and tab group handles have `touch-action: none`, so the page doesn't
scroll instead.
- **A preview follows your finger.** Touch drag and drop has no native drag
image, so Mosaic draws one: the window's `renderPreview`, or the tab's title.
- **Tabs** sit in a strip that scrolls sideways. A quick sideways swipe scrolls
it; moving a tab clearly up or down, or holding it for a moment first, drags it.
While you drag, resting near either end of the strip scrolls it, so you can
reach tabs and drop spots that are out of view.
### Using your own `DndProvider`
If your app already has a `DndProvider`, don't nest a second one with its own
multi-backend inside it. Two multi-backends on the same page fail with
`Cannot have two MultiBackends at the same time`, and components under
different providers can't drag to each other.
Render `MosaicWithoutDragDropContext` instead. It takes the same props as
`Mosaic`, minus the provider:
```tsx
import { DndProvider } from 'react-dnd';
import { MultiBackend } from 'react-dnd-multi-backend';
import { HTML5toTouch } from 'rdndmb-html5-to-touch';
import { MosaicWithoutDragDropContext } from 'react-mosaic-component';
export function App() {
return (
renderTile={/* ... */}
initialValue={/* ... */}
/>
);
}
```
`HTML5toTouch` starts touch drags on the first pixel of movement. To get the
same tap and swipe handling as `Mosaic`, give its touch backend a
`touchSlop` of 10:
```ts
const options = {
...HTML5toTouch,
backends: HTML5toTouch.backends.map((backend) =>
backend.id === 'touch'
? { ...backend, options: { ...backend.options, touchSlop: 10 } }
: backend,
),
};
```
If you create the manager yourself, you can also keep `Mosaic` and pass it the
`dragAndDropManager` prop.
:::note
Your app and react-mosaic must share one copy of react-dnd. Install the same
major versions the library uses (`react-dnd` 16, `react-dnd-multi-backend` 9,
`rdndmb-html5-to-touch` 9) so your package manager dedupes them. This works
with the ESM build, which is what Vite, webpack, Next.js and other bundlers
pick up. The CommonJS build (`index.cjs`) bundles react-dnd inside it, so a
`DndProvider` from your own copy isn't visible to it.
:::
### Dragging items into the layout
Anything that shares Mosaic's drag and drop manager can drop onto its drop
targets. Two things are needed for that to work:
- The drag type must be `MosaicDragType.WINDOW`.
- The drag item must carry the same `mosaicId` as the `Mosaic` it's dropped
on, otherwise the drop zones won't highlight. Pass a fixed `mosaicId` prop
so you know it.
When the item is dropped on a window edge or a root edge,
`monitor.getDropResult()` returns `{ path, position }`, where `position` is
`'top' | 'bottom' | 'left' | 'right'`. Build the new node there and update
your controlled `value`:
```tsx
import { useState } from 'react';
import { DndProvider, useDrag } from 'react-dnd';
import { MultiBackend } from 'react-dnd-multi-backend';
import { HTML5toTouch } from 'rdndmb-html5-to-touch';
import {
MosaicDragType,
MosaicNode,
MosaicPath,
MosaicWindow,
MosaicWithoutDragDropContext,
getNodeAtPath,
updateTree,
} from 'react-mosaic-component';
const MOSAIC_ID = 'main-layout';
type Position = 'top' | 'bottom' | 'left' | 'right';
type DropResult = { path?: MosaicPath; position?: Position };
function insertAt(
tree: MosaicNode,
key: string,
path: MosaicPath,
position: Position,
): MosaicNode {
const target = getNodeAtPath(tree, path)!;
const split: MosaicNode = {
type: 'split',
direction: position === 'left' || position === 'right' ? 'row' : 'column',
children: position === 'left' || position === 'top' ? [key, target] : [target, key],
};
return path.length === 0 ? split : updateTree(tree, [{ path, spec: { $set: split } }]);
}
function PaletteItem({ panelKey, onDrop }: {
panelKey: string;
onDrop: (key: string, path: MosaicPath, position: Position) => void;
}) {
const [, drag] = useDrag({
type: MosaicDragType.WINDOW,
item: { mosaicId: MOSAIC_ID },
end: (_item, monitor) => {
const result = monitor.getDropResult();
if (result?.path && result.position) {
onDrop(panelKey, result.path, result.position);
}
},
});
return (
);
}
```
A few things to keep in mind:
- Leaf keys must be unique, so skip or rename keys that are already in the
tree (`getLeaves(tree)` gives you the current ones).
- `mosaicActions` from `MosaicContext` only exist inside the `Mosaic` tree. An
outside drag source updates the layout through your own state, as above.
- Drops onto a tab bar return `{ path }` without a `position`. The example
ignores those; handle them if you want outside items to become tabs.
- A drop target can tell outside items apart from Mosaic's own windows:
windows dragged from the layout carry `nodeKey` and `path` on the drag item,
your items don't.
### Dragging windows out of the layout
Mosaic's own drags work the other way too: a drop target outside the layout
can accept `MosaicDragType.WINDOW`. The drag item carries `nodeKey` (the
dragged panel's key) and `path` (where it was when the drag started). Tab
groups dragged as a whole only carry `path`.
Return `{ remove: true }` from `drop()` and Mosaic takes the window (or tab)
out of the layout, firing `onChange` and `onRelease` as usual. `remove` wins
if the result also has a `path`. Return nothing and the window snaps back to
where it was.
```tsx
import { useDrop } from 'react-dnd';
import { MosaicDragType } from 'react-mosaic-component';
function Sidebar({ onPanelDropped }: { onPanelDropped: (key: string) => void }) {
const [{ isOver }, drop] = useDrop({
accept: MosaicDragType.WINDOW,
drop: (item: { nodeKey?: string }, monitor) => {
if (monitor.didDrop()) {
return undefined; // already handled by a target inside, e.g. the layout
}
if (item.nodeKey == null) {
return undefined; // e.g. a whole tab group: leave it in the layout
}
onPanelDropped(item.nodeKey);
return { remove: true };
},
collect: (monitor) => ({ isOver: monitor.isOver() }),
});
return (
);
}
```
The sidebar has to share Mosaic's drag and drop manager, so render it under
the same `DndProvider` as `MosaicWithoutDragDropContext`, as shown above.
The `monitor.didDrop()` check matters if the target wraps the Mosaic: react-dnd
calls outer targets after inner ones, and without it every drop inside the
layout would also be treated as a drop on the sidebar and remove the window.
---
## Persisting layout
The `MosaicNode` tree is JSON-serialisable by design — no class instances,
no functions, no circular refs. That means "persist the user's layout" is
`JSON.stringify` + your storage of choice.
### localStorage (synchronous)
```tsx
import { useEffect, useState } from 'react';
import { Mosaic, MosaicWindow, MosaicNode } from 'react-mosaic-component';
const STORAGE_KEY = 'myapp.layout.v1';
const DEFAULT_LAYOUT: MosaicNode = {
type: 'split',
direction: 'row',
children: ['a', 'b'],
};
function loadInitial(): MosaicNode | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as MosaicNode) : DEFAULT_LAYOUT;
} catch {
return DEFAULT_LAYOUT;
}
}
export function App() {
const [tree, setTree] = useState | null>(loadInitial);
return (
value={tree}
onChange={setTree}
onRelease={(finalTree) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(finalTree));
}}
renderTile={(id, path) => (
{id}
)}
/>
);
}
```
The key bit is **`onRelease` rather than `onChange`**. `onChange` fires on
every frame of an ongoing drag; writing to localStorage on every frame
wastes cycles and, for remote storage, makes you rate-limit yourself. Use
`onRelease` to write once per user interaction.
### Versioning the stored shape
Treat your persisted tree like any other on-disk format: if you ever change
it, bump the key.
```ts
const STORAGE_KEY = 'myapp.layout.v2'; // v1 → v2 when the schema changed
```
You can also branch on a version field inside the payload and migrate
forward:
```ts
interface StoredLayout {
version: 2;
tree: MosaicNode;
}
function load(): MosaicNode {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return DEFAULT_LAYOUT;
const stored = JSON.parse(raw) as { version: number; tree: unknown };
if (stored.version === 1) return migrateV1toV2(stored.tree);
return stored.tree as MosaicNode;
}
```
### Remote storage
For server-backed persistence, the pattern is the same but the write is
async. Debounce so that rapid `onRelease` calls collapse into one request:
```tsx
import { useMemo } from 'react';
import debounce from 'lodash-es/debounce';
const save = useMemo(
() =>
debounce((next: MosaicNode) => {
fetch('/api/layout', {
method: 'PUT',
body: JSON.stringify(next),
});
}, 500),
[],
);
```
### Legacy binary trees
If you're loading data persisted by react-mosaic v6 or earlier, it will be
in the binary `first`/`second` shape. You have two options:
1. **Lazy conversion** — pass the legacy tree straight to ``;
the component converts it on render. Your `onChange` will emit the
n-ary form, so the next save overwrites the legacy shape automatically.
2. **Explicit conversion** — call `convertLegacyToNary` on load and save
the converted tree immediately.
Explicit conversion is preferred for long-lived data because it bakes the
migration in at load time, instead of leaving mixed-format trees in your
store.
See the [Migration from v6](../migration/from-v6) page for the full
breakdown of shape differences.
---
## Recipes
Small patterns that come up a lot and don't need anything beyond the public
API.
### Maximize a panel
To show a single panel on its own, keep the full layout in state, replace
`value` with the panel's key, and put the saved layout back when restoring.
A bare key is a valid tree, so Mosaic renders it as one full-size window.
```tsx live
function MaximizeExample() {
const [tree, setTree] = React.useState({
type: 'split',
direction: 'row',
children: ['a', { type: 'split', direction: 'column', children: ['b', 'c'] }],
});
const [savedTree, setSavedTree] = React.useState(null);
const isMaximized = savedTree !== null;
const toggleMaximize = (id) => {
if (isMaximized) {
setTree(savedTree);
setSavedTree(null);
} else {
setSavedTree(tree);
setTree(id);
}
};
return (
(
toggleMaximize(id)}
/>
}
>
Panel {id}
)}
/>
);
}
```
Because you own the maximized state, you also decide what else is allowed in
it. The example only shows a Restore button, so the user can't remove or
split the panel while the rest of the layout is stashed away.
If you only want the panel to take most of the space and keep the others
visible, `mosaicActions.expand(path, percentage)` from `MosaicContext` does
that without swapping the tree.
### Drag the whole tile, without a toolbar
`MosaicWindowContext` exposes `connectDragSource`, the same connector the title
bar uses. Wrap any element inside the window with it and that element becomes a
drag handle. Here it's the whole body, and the toolbar is hidden with CSS:
```tsx live
function WholeTileDragExample() {
function DragAnywhere({ children }) {
const { mosaicWindowActions } = React.useContext(MosaicWindowContext);
return mosaicWindowActions.connectDragSource(
);
}
```
`draggable={false}` stops the (now hidden) toolbar from being a drag handle.
`renderToolbar` replaces the default title and buttons with an empty element.
If your tiles have inputs or selectable text, wrap a smaller area (a grip
icon, a header) instead of the whole body so those keep working.
### Swap tiles instead of splitting
By default, dropping a window on another one splits it along the edge you drop
on. Set `dropBehavior` to change that:
- `'swap'`: the two windows trade places. Every pane keeps its size, and the
only drop target is the whole window.
- `'split-and-swap'`: the edges still split, and the centre of a window swaps.
```tsx live
function SwapExample() {
const [dropBehavior, setDropBehavior] = React.useState('swap');
// Keep one layout object, so switching modes doesn't reset your swaps
const [initialLayout] = React.useState({
type: 'split',
direction: 'row',
splitPercentages: [30, 70],
children: [
'a',
{
type: 'split',
direction: 'column',
splitPercentages: [60, 40],
children: ['b', 'c'],
},
],
});
return (
<>
(
Drag my title onto another panel
)}
initialValue={initialLayout}
/>
>
);
}
```
To swap nodes from your own code, `createSwapUpdates(tree, pathA, pathB)`
returns the updates for `updateTree`.
### Know which panel was removed
`onChange` gets a second `meta` argument that says what changed. Panels leave
the layout in three ways: a removal carries the removed node (a panel key, or a
whole subtree), closing a tab carries the tab, and a replace carries what was
there before:
```tsx
onChange={(next, meta) => {
if (meta?.type === 'remove') onPanelsRemoved(getLeaves(meta.node));
if (meta?.type === 'tab-remove') onPanelsRemoved([meta.tab]);
if (meta?.type === 'replace' && meta.previous != null) {
onPanelsRemoved(getLeaves(meta.previous));
}
setTree(next);
}}
```
On versions before `meta` was added, compare leaves instead:
`getLeaves(tree).filter((id) => !getLeaves(next).includes(id))`.
### A layout bigger than the screen
`Mosaic` fills its parent. To get a scrollable canvas, give it a larger parent
inside a scroll container:
```tsx
```
---
## Resizing
Users resize panes by dragging the dividers between them. The `resize` prop on
`Mosaic` controls how that works. Every option is optional, and leaving `resize`
out keeps the defaults.
```tsx
,
preview: true,
}}
renderTile={/* ... */}
initialValue={/* ... */}
/>
```
To turn resizing off completely, pass `resize="DISABLED"`. The dividers are
then not rendered at all.
### Try it
This layout keeps panes at least 80px wide or tall, shows a grip on every
divider, and only moves the divider while you drag. The panes resize when you
let go.
```tsx live
function ResizingExample() {
const grip = (direction) => (
);
return (
);
}
```
### Minimum pane size
Two options limit how small a pane can get. Both apply at the same time and the
larger one wins.
- `minimumPaneSizePercentage` is a percentage of the split the pane is in.
Default: `10`.
- `minimumPaneSizePx` is the visible size of the tile in pixels. The 6px gutter
around each tile is added for you. Default: `0`.
Either one also takes `{ row, column }` to use different values per split
direction. `row` splits put panes side by side, `column` splits stack them. For
example, to let side-by-side panes shrink all the way while stacked panes keep
room for a 30px title bar:
```tsx
```
A direction you leave out of the object falls back to the default. The minimums
apply to the two panes next to the divider you drag, not to panes nested inside
them: dragging a divider can still squeeze a nested split's panes below the
minimum. They also only apply while dragging. They don't change a layout you
pass in, and they aren't re-applied when the container itself gets smaller.
### Custom divider handles
`renderSplitHandle(direction)` renders your content centered on every divider,
inside a `.mosaic-split-handle` element. Pressing it drags the divider like the
rest of the bar, and the divider keeps its position relative to where you
grabbed it, so a wide grip doesn't jump to the cursor. Use `direction` to draw a
vertical grip on `row` dividers and a horizontal one on `column` dividers.
Dividers with a handle are stacked above tile content, including the window
toolbar and the drop targets shown while dragging a window, so a grip that is
larger than the 6px bar covers whatever is under it. Outer dividers stack above
inner ones so their grips stay grabbable where dividers meet. That ordering goes
eight levels deep; below that, dividers share the same level and a nested one
can cover an outer grip.
### Preview while dragging
By default the panes resize live, and `onChange` fires as the divider moves.
With `preview: true`, only the divider moves while dragging. It gets a
`-preview` class and the same look as on hover. The panes resize once, when the
divider is released, so `onChange` and `onRelease` fire a single time per drag.
That helps when tiles are expensive to re-render.
While any divider is being dragged, the tiles don't receive pointer events, so
iframes and other apps inside them can't interrupt the drag.
---
## Theming
react-mosaic ships a neutral default theme plus a Blueprint-integrated one.
Both are opt-in: the library renders nothing visible until you pull in a
stylesheet, and which stylesheet you pull in decides the look.
### The required stylesheet
You must import the library CSS somewhere in your app:
```ts
import 'react-mosaic-component/react-mosaic-component.css';
```
This bundles the layout rules (split bars, drop targets, drag preview) and
the default theme in one file. Without it, panels render but have no
dividers, no hover states, and no drop indicators.
### Applying a theme: `className` on ``
The `className` prop on `Mosaic` is how you pick a theme. The library
recognises two built-in values:
- `'mosaic-blueprint-theme'` — matches Blueprint's default (light) palette.
- `'mosaic-blueprint-theme bp5-dark'` — Blueprint dark mode. (Combine with
`blueprintNamespace="bp5"` so internal icons render via Blueprint's icon
font.)
```tsx
className="mosaic-blueprint-theme bp5-dark"
blueprintNamespace="bp5"
renderTile={/* ... */}
initialValue={/* ... */}
/>
```
If you're not using Blueprint at all, leave `className` empty — the default
neutral theme is always on.
### Writing your own theme
Every visual rule in the library is namespaced under `.mosaic-*`. To theme
your own panels, add a class to `Mosaic` and scope overrides to it:
```css
.my-theme.mosaic {
background: #0b1220;
}
.my-theme .mosaic-window {
background: #111a2c;
border: 1px solid #1f2a44;
border-radius: 6px;
}
.my-theme .mosaic-window-title {
background: linear-gradient(90deg, #1a2236, #111a2c);
color: #cbd5e0;
}
.my-theme .mosaic-split:hover {
background: #4c90f0;
}
```
```tsx
```
You don't need to restyle everything — the default theme is the base, and
your class overrides only what you specify.
### The class names that matter
When writing a theme, these are the selectors you'll most often target:
| Selector | What it is |
| --- | --- |
| `.mosaic` | Root container |
| `.mosaic-window` | Individual panel |
| `.mosaic-window-title` | Title bar area |
| `.mosaic-window-body` | Panel content area |
| `.mosaic-window-toolbar` | Whole title bar (title + controls) |
| `.mosaic-window-controls` | Right-hand button cluster |
| `.mosaic-split` | Draggable divider between siblings |
| `.mosaic-split.-row` / `.-column` | Direction-specific divider |
| `.mosaic-split-line` | The visible line inside a divider |
| `.mosaic-drop-target` | Hover target shown during drag |
| `.mosaic-tab-bar` | Tab strip of a tab group |
| `.mosaic-tab-button` | Single tab header |
| `.mosaic-tab-button.-active` | The currently-selected tab header |
| `.mosaic-zero-state` | Empty-state placeholder |
Class names are `mosaic-`, and states are separate `-modifier`
classes (`-row`, `-active`, `-dragging`). If you can't find a class, inspect a
running demo in DevTools; the names are stable.
### Styling the split bar
A `row` split has a vertical divider and a `column` split has a horizontal
one. Style the divider itself for hover effects. Note there is no space before
`:hover`:
```css
.my-theme .mosaic-split.-row:hover {
background: #4c90f0;
}
```
With the Blueprint theme, the theme already sets the hover background, so
match its specificity to override it, or style the inner line instead:
```css
.mosaic.mosaic-blueprint-theme .mosaic-split.-row:hover {
background: #4c90f0;
}
/* or keep the background and recolor the line */
.mosaic.mosaic-blueprint-theme .mosaic-split:hover .mosaic-split-line {
box-shadow: 0 0 0 2px #4c90f0;
}
```
### Live example
Flip between themes in the [demo app](/demo) — the theme selector in the
toolbar toggles `className` on a live `` instance. That's the whole
integration: one string.
---
## Customising the empty state
When the layout value is `null` — the user removed the last panel, or the
tree hasn't been initialised — `Mosaic` renders a `zeroStateView`. Out of
the box that's the `MosaicZeroState` component, a neutral placeholder with
a "create new" button.
### Using the default
`MosaicZeroState` ships with the library and is the sensible default:
```tsx live
function ZeroStateDefault() {
return (
(
{id}
)}
initialValue={null}
zeroStateView={}
/>
);
}
```
Click the "Create New Window" button and the mosaic transitions out of the
empty state. Under the hood that's the `createNode` prop being invoked and
the result set as the new root.
### `createNode`: where new panels come from
`MosaicZeroState`'s button — and the default toolbar's "split" button —
both call `createNode` to ask you for a new leaf key. If you don't provide
one, the library has no way to invent identifiers for your domain:
```tsx
import { useRef } from 'react';
function App() {
const nextId = useRef(1);
const createNode = () => `panel-${nextId.current++}`;
return (
renderTile={/* ... */}
createNode={createNode}
zeroStateView={}
initialValue={null}
/>
);
}
```
`createNode` can return a value synchronously or a promise. Returning a
promise is how you implement "open a picker, let the user choose what to
put in the new panel, then resolve with its key":
```tsx
const createNode = async (): Promise => {
const choice = await openPanelPicker();
return choice.id;
};
```
While the promise is pending, the split/creation action waits — no panel
appears until you resolve.
### A fully custom zero state
Pass any React node as `zeroStateView`. The obvious use case is branding
the empty state to fit your app:
```tsx
const zeroState = (
No panels open
Drag something from the sidebar, or start from a template.
);
```
Because it's just React, you can wire it up to your own state, fetch
templates from the server, or embed a full onboarding flow — the library
treats it as an opaque node.
### When to use `null` vs a single leaf
If you always want *something* on screen, pass a single leaf as your
initial value:
```ts
const INITIAL: MosaicNode = 'welcome';
```
The user can still remove it (ending up at the zero state), but the
first-load experience shows content instead of a placeholder. Use `null`
for apps where "nothing open" is a real, valid state — IDE-style tools
where tabs are transient are a good fit.
---
## Migrating from v6
v7 replaced the binary tree layout with an n-ary tree. If you're coming from
v6 or earlier, your layouts still work — ``
normalises the shape on first render — but the modern API is worth adopting.
### What changed
| Concept | v6 (binary) | v7 (n-ary) |
| ------------------- | ---------------------------------------- | --------------------------------------------------------- |
| Split node shape | `{ direction, first, second }` | `{ type: 'split', direction, children: [...] }` |
| Split sizing | `splitPercentage: number` | `splitPercentages: number[]` (array sums to 100) |
| Path representation | `MosaicBranch[]` — `['first', 'second']` | `number[]` — `[0, 1]` |
| Tab support | None (had to nest splits manually) | First-class `{ type: 'tabs', tabs, activeTabIndex }` node |
| Max children | Always 2 | Any number per split |
### Legacy tree
```ts
const oldTree = {
direction: 'row',
first: 'panel1',
second: {
direction: 'column',
first: 'panel2',
second: 'panel3',
splitPercentage: 60,
},
splitPercentage: 40,
};
```
### Modern equivalent
```ts
const newTree: MosaicNode = {
type: 'split',
direction: 'row',
splitPercentages: [40, 60],
children: [
'panel1',
{
type: 'split',
direction: 'column',
splitPercentages: [60, 40],
children: ['panel2', 'panel3'],
},
],
};
```
### `convertLegacyToNary`
If you have a persisted layout in your database that you want to normalise
_before_ hitting ``, use the conversion helper:
```ts
import { convertLegacyToNary } from 'react-mosaic-component';
const modernTree = convertLegacyToNary(oldTree);
```
This is the same function `` calls internally, so the result is
guaranteed to be equivalent.
### Automatic conversion
You don't _have_ to migrate anything proactively. ``
accepts legacy shapes and converts them on the fly. The recommended workflow:
1. Ship v7 with your existing layouts unchanged — it Just Works.
2. Convert stored layouts lazily: when a user saves, normalise with
`convertLegacyToNary` so the new shape gets persisted.
3. Once all stored layouts are normalised, delete the legacy branches from
your codebase.
### Type exports for the transition
The legacy types are still exported for as long as you need them:
```ts
import type {
LegacyMosaicNode,
LegacyMosaicParent,
LegacyMosaicBranch,
LegacyMosaicPath,
} from 'react-mosaic-component';
```
- `LegacyMosaicNode` — a whole v6 tree (a parent or a leaf key)
- `LegacyMosaicParent` — the old binary split shape
- `LegacyMosaicBranch` — `'first' | 'second'`
- `LegacyMosaicPath` — `LegacyMosaicBranch[]`, e.g. `['first', 'second']`
`MosaicDirection` (`'row' | 'column'`) didn't change.
New code should use `MosaicNode`, `MosaicSplitNode`, and
`MosaicTabsNode` instead.