# 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 (
renderTile={(id, path) => ( path={path} title={`Panel ${id}`}>
Contents of {id}
)} initialValue={{ type: 'split', direction: 'row', children: ['a', 'b'], }} />
); } ``` 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 (
(
Panel {id}
)} initialValue={{ type: 'split', direction: 'row', children: ['a', 'b', 'c'], }} />
); } ``` 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 (
(
Tab: {id}
)} initialValue={{ type: 'split', direction: 'row', children: [ 'sidebar', { type: 'tabs', tabs: ['inbox', 'drafts', 'sent'], activeTabIndex: 0, }, ], }} />
); } ``` 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 (
(
{id}
)} initialValue={{ type: 'split', direction: 'row', splitPercentages: [30, 40, 30], children: [ 'left', { type: 'tabs', tabs: ['inbox', 'drafts', 'sent'], activeTabIndex: 0, }, { type: 'split', direction: 'column', splitPercentages: [60, 40], children: ['main', 'console'], }, ], }} />
); } ``` ### 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 = (
); return (
(
Edit buttonColor above to re-theme the toolbar.
)} initialValue={{ type: 'split', direction: 'row', children: ['left', 'right'], }} />
); } ``` ### 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 = (
); return (
(
Panel {id}
)} initialValue={{ type: 'split', direction: 'row', children: ['a', 'b'] }} />
); } ``` ### 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 (
(
Open: {id} {isMarkdown(id) &&
(Preview available)
}
)} renderTabToolbarControls={({ tabs, activeTabIndex, path }) => ( <> {isMarkdown(tabs[activeTabIndex]) && ( alert('preview ' + tabs[activeTabIndex])} /> )} )} initialValue={{ type: 'tabs', tabs: ['readme.md', 'index.ts', 'notes.md'], activeTabIndex: 0, }} />
); } ``` 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 (
`tab-${++counter}`} renderTile={(id, path) => (
Panel {id}
)} renderTabToolbarControls={({ tabs, path }) => ( <> {tabs.length < MAX_TABS && } )} initialValue={{ type: 'tabs', tabs: ['a', 'b'], activeTabIndex: 0, }} />
); } ``` 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 (
`tab-${++counter}`} renderTile={(id, path) => (
Panel {id}
)} renderTabToolbarControls={({ path }) => ( <> )} initialValue={{ type: 'tabs', tabs: ['a', 'b'], activeTabIndex: 0, }} />
); } ``` 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 (
{ drag(element); }} > {panelKey}
); } export function App() { const [tree, setTree] = useState>('a'); const handleDrop = (key: string, path: MosaicPath, position: Position) => setTree((current) => insertAt(current, key, path, position)); return (
mosaicId={MOSAIC_ID} value={tree} onChange={(next) => next && setTree(next)} renderTile={(id, path) => ( path={path} title={id}> {id} )} />
); } ``` 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(
{children}
, ); } return (
(
}> Drag me anywhere: {id} )} initialValue={{ type: 'split', direction: 'row', children: ['a', { type: 'split', direction: 'column', children: ['b', 'c'] }], }} />
); } ``` `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 (
(
Panel {id}
)} initialValue={{ type: 'split', direction: 'row', children: [ 'a', { type: 'split', direction: 'column', children: ['b', 'c'] }, ], }} />
); } ``` ### 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.