@heojeongbo/react-mosaic-ui
v2.2.6
Published
A modern React tiling window manager with FSD architecture, Rollup bundling, and Tailwind CSS v4
Maintainers
Readme
react-mosaic-ui
A modern React tiling window manager with drag-and-drop, resizable splits, and full TypeScript support.
Inspired by react-mosaic
Installation
# npm
npm install @heojeongbo/react-mosaic-ui
# yarn
yarn add @heojeongbo/react-mosaic-ui
# pnpm
pnpm add @heojeongbo/react-mosaic-ui
# bun
bun add @heojeongbo/react-mosaic-uiPeer dependencies (if not already installed):
bun add react react-domQuick Start
import { useState } from 'react';
import { Mosaic, MosaicWindow, type MosaicNode } from '@heojeongbo/react-mosaic-ui';
import '@heojeongbo/react-mosaic-ui/styles.css';
type ViewId = 'editor' | 'preview' | 'terminal';
const TITLES: Record<ViewId, string> = {
editor: 'Editor',
preview: 'Preview',
terminal: 'Terminal',
};
export default function App() {
const [tree, setTree] = useState<MosaicNode<ViewId> | null>({
direction: 'row',
first: 'editor',
second: {
direction: 'column',
first: 'preview',
second: 'terminal',
splitPercentage: 60,
},
splitPercentage: 60,
});
return (
<div style={{ width: '100vw', height: '100vh' }}>
<Mosaic<ViewId>
value={tree}
onChange={setTree}
renderTile={(id, path) => (
<MosaicWindow title={TITLES[id]} path={path} createNode={() => 'editor'}>
<div style={{ padding: 16 }}>Content: {id}</div>
</MosaicWindow>
)}
/>
</div>
);
}Core Concepts
Tree Structure
A layout is represented as a binary tree. Leaf nodes are tile IDs (string | number), and parent nodes describe how to split the space.
type MosaicNode<T> = T | MosaicParent<T>;
interface MosaicParent<T> {
direction: 'row' | 'column'; // row = left/right split, column = top/bottom split
first: MosaicNode<T>;
second: MosaicNode<T>;
splitPercentage?: number; // 0–100, defaults to 50
}Example tree (3 tiles):
const tree: MosaicNode<string> = {
direction: 'row',
first: 'a',
second: {
direction: 'column',
first: 'b',
second: 'c',
},
};Controlled vs Uncontrolled
Controlled — you own the state:
const [tree, setTree] = useState<MosaicNode<string> | null>(initialTree);
<Mosaic value={tree} onChange={setTree} renderTile={renderTile} />Uncontrolled — the component manages state internally:
<Mosaic initialValue={initialTree} renderTile={renderTile} />API Reference
<Mosaic>
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| renderTile | (id: T, path: MosaicPath) => JSX.Element | required | Renders each leaf tile |
| value | MosaicNode<T> \| null | — | Controlled tree value |
| initialValue | MosaicNode<T> \| null | — | Uncontrolled initial value |
| onChange | (node: MosaicNode<T> \| null) => void | — | Called on every tree change |
| onRelease | (node: MosaicNode<T> \| null) => void | — | Called when drag/resize is released |
| className | string | — | Extra class on the root element |
| zeroStateView | JSX.Element | built-in | Shown when tree is null |
| mosaicId | string | auto | ID for multi-mosaic DnD isolation |
| createNode | () => T \| Promise<T> | — | Factory for new tiles (enables split/replace) |
| resize | ResizeOptions | — | Override minimum pane size |
<MosaicWindow>
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| title | string | required | Toolbar title |
| path | MosaicPath | required | Position in the tree (passed from renderTile) |
| children | ReactNode | required | Window body content |
| createNode | () => T \| Promise<T> | — | Enables Split and Replace toolbar buttons |
| draggable | boolean | true | Whether the window can be dragged |
| toolbarControls | ReactNode | — | Replaces the default toolbar buttons entirely |
| additionalControls | ReactNode | — | Extra controls shown in a collapsible drawer |
| renderToolbar | (props, defaultToolbar) => ReactNode | — | Full toolbar override (receives default as second arg) |
| onDragStart | () => void | — | Called when drag begins |
| onDragEnd | (type: 'drop' \| 'reset') => void | — | Called when drag ends |
| className | string | — | Extra class on the window element |
Built-in toolbar buttons (visible when createNode is provided):
| Button | Description | |--------|-------------| | Split | Splits the window in half | | Replace | Replaces the window with a new tile | | Expand | Expands to 70% of the parent | | Close | Removes the window from the layout |
Utility Functions
import {
// Tree inspection
getLeaves, // Get all leaf IDs in order
isParent, // Check if a node is a parent
getNodeAtPath, // Get node at a given path
getAndAssertNodeAtPathExists, // Same, throws if not found
countNodes, // Count total nodes in the tree
getTreeDepth, // Get maximum depth
arePathsEqual, // Compare two MosaicPath arrays
getPathToCorner, // Get path to a corner tile
getOtherDirection, // 'row' ↔ 'column'
getOtherBranch, // 'first' ↔ 'second'
// Tree building
createBalancedTreeFromLeaves, // Build balanced tree from array of IDs
// Update generators (use with updateTree or mosaicActions.updateTree)
updateTree, // Apply an array of MosaicUpdate to a tree
createRemoveUpdate, // Remove a tile
createExpandUpdate, // Expand a tile to a percentage
createHideUpdate, // Hide a tile (DnD internal)
createReplaceUpdate, // Replace a tile with another node
createSplitUpdate, // Split a tile into two
createDragToUpdates, // Move a tile via drag
} from '@heojeongbo/react-mosaic-ui';Common patterns
// Get all tile IDs currently in the layout
const ids = getLeaves(tree); // ['editor', 'preview', 'terminal']
// Auto-arrange: rebuild a balanced layout from existing tiles
const balanced = createBalancedTreeFromLeaves(getLeaves(tree));
setTree(balanced);
// Remove a specific tile programmatically
const update = createRemoveUpdate(tree, pathToTile);
setTree(updateTree(tree, [update]));Contexts
For advanced use cases you can read the mosaic state directly from context:
import { MosaicContext, MosaicWindowContext } from '@heojeongbo/react-mosaic-ui';
import { useContext } from 'react';
// Inside a tile rendered by renderTile:
function MyTile() {
const { mosaicActions, mosaicId } = useContext(MosaicContext);
const { mosaicWindowActions } = useContext(MosaicWindowContext);
return (
<button onClick={() => mosaicActions.remove(mosaicWindowActions.getPath())}>
Close me
</button>
);
}MosaicRootActions (via MosaicContext.mosaicActions):
| Method | Description |
|--------|-------------|
| expand(path, percentage?) | Expand node to percentage (default 70%) |
| remove(path) | Remove node at path |
| hide(path) | Hide node (used internally by DnD) |
| replaceWith(path, node) | Replace node at path |
| updateTree(updates, suppressOnRelease?) | Apply multiple updates atomically |
| getRoot() | Get current root node |
MosaicWindowActions (via MosaicWindowContext.mosaicWindowActions):
| Method | Description |
|--------|-------------|
| split() | Split the current window |
| replaceWithNew() | Replace current window with a new tile |
| getPath() | Get current window's path in the tree |
Style Customization
Import the stylesheet once at your app entry point:
import '@heojeongbo/react-mosaic-ui/styles.css';Override CSS variables to theme the layout:
:root {
--rm-border-color: #cbd5e1;
--rm-background: #ffffff;
--rm-window-bg: #f8fafc;
--rm-toolbar-bg: #f1f5f9;
--rm-split-color: #94a3b8;
--rm-split-hover: #64748b;
--rm-split-size: 4px; /* Width/height of the resize handle */
--rm-toolbar-height: 40px;
}All internal class names use the rm- prefix and are scoped under .react-mosaic, so they won't conflict with your own styles.
Custom toolbar
<MosaicWindow
title="My Window"
path={path}
renderToolbar={(props, defaultToolbar) => (
<div className="my-toolbar">
<span>{props.title}</span>
<div className="actions">{defaultToolbar}</div>
</div>
)}
>
<div>Content</div>
</MosaicWindow>Additional controls (drawer)
<MosaicWindow
title="My Window"
path={path}
additionalControls={
<>
<button onClick={handleExport}>Export</button>
<button onClick={handleSettings}>Settings</button>
</>
}
>
<div>Content</div>
</MosaicWindow>Advanced Examples
Dynamic tile creation
let nextId = 1;
function App() {
const [tree, setTree] = useState<MosaicNode<number> | null>(1);
return (
<Mosaic<number>
value={tree}
onChange={setTree}
createNode={() => ++nextId}
renderTile={(id, path) => (
<MosaicWindow title={`Window ${id}`} path={path} createNode={() => ++nextId}>
<div>Content {id}</div>
</MosaicWindow>
)}
/>
);
}Adding a new window to an existing layout
import { createBalancedTreeFromLeaves, getLeaves } from '@heojeongbo/react-mosaic-ui';
function addWindow(tree: MosaicNode<string> | null, newId: string) {
const current = getLeaves(tree ?? []);
return createBalancedTreeFromLeaves([...current, newId]);
}Multiple independent mosaics on one page
<Mosaic mosaicId="mosaic-left" value={leftTree} onChange={setLeft} renderTile={renderTile} />
<Mosaic mosaicId="mosaic-right" value={rightTree} onChange={setRight} renderTile={renderTile} />Tiles can only be dragged within the same mosaicId.
onRelease — respond after resize or drag completes
<Mosaic
value={tree}
onChange={setTree}
onRelease={(newTree) => {
// save layout to server/localStorage after user finishes dragging
saveLayout(newTree);
}}
renderTile={renderTile}
/>Development
# Install dependencies
bun install
# Build library
bun run build
# Run tests (200 tests)
bun run test
# Type check
bun run typecheck
# Lint
bun run lint
# Run all checks
bun run check
# Run the example app
cd example && bun install && bun run devRelease
bun run release:patch # 2.2.1 → 2.2.2
bun run release:minor # 2.2.1 → 2.3.0
bun run release:major # 2.2.1 → 3.0.0
bun run release:dry # dry run (no publish)The release process runs lint + typecheck + tests, builds, bumps the version, generates CHANGELOG, tags the commit, pushes to GitHub, and publishes to npm.
Tech Stack
| Tool | Purpose |
|------|---------|
| React 18 / 19 | UI |
| TypeScript 5 | Type safety |
| Rollup | Library bundler |
| Tailwind CSS v4 | Styling (rm- prefix) |
| React DnD | Drag and drop |
| Immer | Immutable tree updates |
| Vitest | Testing |
| Bun | Package manager & scripts |
License
MIT
