Drag and drop integration
Mosaic uses 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
<Mosaic> 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:
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 (
<DndProvider backend={MultiBackend} options={HTML5toTouch}>
<YourOtherDraggableStuff />
<MosaicWithoutDragDropContext<string>
renderTile={/* ... */}
initialValue={/* ... */}
/>
</DndProvider>
);
}
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:
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.
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
mosaicIdas theMosaicit's dropped on, otherwise the drop zones won't highlight. Pass a fixedmosaicIdprop 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:
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<string>,
key: string,
path: MosaicPath,
position: Position,
): MosaicNode<string> {
const target = getNodeAtPath(tree, path)!;
const split: MosaicNode<string> = {
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<DropResult>();
if (result?.path && result.position) {
onDrop(panelKey, result.path, result.position);
}
},
});
return (
<div
ref={(element) => {
drag(element);
}}
>
{panelKey}
</div>
);
}
export function App() {
const [tree, setTree] = useState<MosaicNode<string>>('a');
const handleDrop = (key: string, path: MosaicPath, position: Position) =>
setTree((current) => insertAt(current, key, path, position));
return (
<DndProvider backend={MultiBackend} options={HTML5toTouch}>
<PaletteItem panelKey="b" onDrop={handleDrop} />
<PaletteItem panelKey="c" onDrop={handleDrop} />
<div style={{ height: 500 }}>
<MosaicWithoutDragDropContext<string>
mosaicId={MOSAIC_ID}
value={tree}
onChange={(next) => next && setTree(next)}
renderTile={(id, path) => (
<MosaicWindow<string> path={path} title={id}>
{id}
</MosaicWindow>
)}
/>
</div>
</DndProvider>
);
}
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). mosaicActionsfromMosaicContextonly exist inside theMosaictree. An outside drag source updates the layout through your own state, as above.- Drops onto a tab bar return
{ path }without aposition. 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
nodeKeyandpathon 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.
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 (
<aside
ref={(element) => {
drop(element);
}}
className={isOver ? 'sidebar drop-here' : 'sidebar'}
>
Drop a panel here to dock it
</aside>
);
}
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.