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} />High-Level API
For most use cases you don't need to manage the tree manually. MosaicLayout + useMosaicPanels handles panel state and tree structure automatically.
import { MosaicLayout, useMosaicPanels } from '@heojeongbo/react-mosaic-ui';
import '@heojeongbo/react-mosaic-ui/styles.css';
function App() {
const { panels, addPanel, removePanel } = useMosaicPanels();
return (
<>
<button
onClick={() =>
addPanel({ id: 'a', title: 'Panel A', content: <div>Hello from A</div> })
}
>
Add Panel
</button>
<div style={{ width: '100vw', height: '100vh' }}>
<MosaicLayout panels={panels} />
</div>
</>
);
}<MosaicLayout>
Renders a mosaic from a flat array of panel configs. Adding or removing panels surgically updates the tree — existing panel positions are preserved.
MosaicPanelConfig<T>
| Field | Type | Description |
|---|---|---|
id |
T |
Unique identifier |
title |
string |
Window toolbar title |
content |
ReactNode |
Window body |
renderToolbar |
() => ReactNode |
Optional custom toolbar content |
Wrapper |
ComponentType<{ children: ReactNode }> |
Optional wrapper around the entire window |
closable |
boolean |
Optional — show/hide the close button |
hideToolbar |
boolean |
Optional — render this panel's window with no toolbar chrome |
bodyPadding |
string | number |
Optional — inline padding override for this panel's body |
bodyClassName |
string |
Optional — extra class on this panel's body |
MosaicLayout props
| Prop | Type | Description |
|---|---|---|
panels |
MosaicPanelConfig<T>[] |
required — panels to display |
initialNode |
MosaicNode<T> | null |
Starting tree, read once on mount. Falls back to a balanced tree built from panels when omitted |
onNodeChange |
(node: MosaicNode<T> | null) => void |
Called whenever the tree changes (resize, drag, add/remove) |
onPanelClose |
(id: T) => void |
Called when a panel is closed |
getDirection |
(nextCount: number) => 'row' | 'column' |
Split direction used when a panel is added (nextCount = leaf count after the add) |
className |
string |
Extra CSS class |
zeroStateView |
JSX.Element |
Shown when panels array is empty |
...rest |
— | All other <Mosaic> props except renderTile, value, onChange, initialValue |
useMosaicPanels<T>()
Hook for managing panel state. Pair with <MosaicLayout>.
| Return | Type | Description |
|---|---|---|
panels |
MosaicPanelConfig<T>[] |
Current panels array |
addPanel(config) |
void |
Add panel; no-op if id already exists |
removePanel(id) |
void |
Remove panel by id |
togglePanel(config) |
void |
Add if absent, remove if present |
hasPanel(id) |
boolean |
Check if panel with id exists |
clearPanels() |
void |
Remove all panels |
updatePanel(id, updates) |
void |
Patch an existing panel (id is immutable) |
getPanelById(id) |
MosaicPanelConfig<T> | null |
Look up a panel by id |
const { panels, addPanel, removePanel, updatePanel, getPanelById } = useMosaicPanels();
// Add
addPanel({ id: 'logs', title: 'Logs', content: <LogViewer /> });
// Update title without changing id
updatePanel('logs', { title: 'Application Logs' });
// Look up
const panel = getPanelById('logs'); // MosaicPanelConfig | null
// Remove
removePanel('logs');usePersistedLayout<T>()
Saves the layout to localStorage and restores it on reload. Panels are described by a registry that maps each id to its component(s) — only panel ids and the tree (with split percentages) are persisted, and the registry rebuilds the MosaicPanelConfigs on restore. Saving is manual (saveLayout()) by default; pass autoSaveDelayMs to debounce-save automatically. Restoring is automatic on mount.
The registry shape mirrors a real-world config map, so an existing one can be passed as-is:
PersistedPanelEntry
| Field | Type | Description |
|---|---|---|
component |
ComponentType<P> |
Rendered as the panel body |
componentProps |
P |
Optional props forwarded to component at render time |
toolbar |
ComponentType |
Optional custom toolbar component |
wrapper |
ComponentType<{ children: ReactNode }> |
Optional wrapper (e.g. a context provider) |
closable |
boolean |
Optional — show/hide the close button |
title |
string |
Optional window title (overridden by the titles option) |
Options
| Option | Type | Description |
|---|---|---|
storageKey |
string |
required — localStorage key |
registry |
PersistedLayoutRegistry<T> |
required — Record<T, PersistedPanelEntry> |
titles |
Partial<Record<T, string>> |
Optional id → window title map; falls back to String(id) |
defaultPanelIds |
T[] |
Panels shown on a clean first run; defaults to all registry keys |
autoSaveDelayMs |
number |
Opt-in: debounce-persist this many ms after the tree changes (no manual saveLayout() needed) |
syncAcrossTabs |
boolean |
Opt-in: re-hydrate from the storage event when another tab saves the same storageKey (default single-tab) |
onSave / onReset |
(node) => void |
Called after saveLayout() / resetLayout() |
onPanelOpen / onPanelClose |
(id) => void |
Called when a panel is shown / hidden |
onNodeChange |
(node) => void |
Called on every tree change |
Return
| Return | Type | Description |
|---|---|---|
panels |
MosaicPanelConfig<T>[] |
Pass to <MosaicLayout panels={...} /> |
initialNode |
MosaicNode<T> | null |
Pass to <MosaicLayout initialNode={...} /> |
onNodeChange |
(node) => void |
Wire to <MosaicLayout onNodeChange={...} /> to track the live tree |
saveLayout() |
void |
Persist the latest tree (call from a Save button) |
addPanel(id) |
void |
Show a panel; no-op if id is not in the registry |
removePanel(id) |
void |
Hide a panel |
hasPanel(id) |
boolean |
Whether a panel id is currently shown |
activeIds |
ReadonlySet<T> |
Currently visible panel ids |
isDirty |
boolean |
Whether the layout changed since the last saveLayout() |
clearLayout() |
void |
Hide all panels (does not touch storage) |
resetLayout() |
void |
Clear stored layout and restore the default panel set |
PanelStateProvider |
ComponentType |
Wrap <MosaicLayout> with it to enable usePanelState() (see below) |
import { MosaicLayout, usePersistedLayout } from '@heojeongbo/react-mosaic-ui';
import type { PersistedLayoutRegistry } from '@heojeongbo/react-mosaic-ui';
// Same shape as a real STATIC_SENSOR_CONFIG_REGISTRY — no titles inline.
const REGISTRY: PersistedLayoutRegistry = {
'encoder-data': { component: EncoderData, toolbar: EncoderToolbar, wrapper: EncoderProvider },
'motor-position': { component: MotorPosition },
'2d-lidar': { component: Lidar2D },
};
const TITLES = { 'encoder-data': 'Encoder', 'motor-position': 'Motor', '2d-lidar': '2D LiDAR' };
function Dashboard() {
const { panels, initialNode, onNodeChange, saveLayout } = usePersistedLayout({
storageKey: 'dashboard-layout',
registry: REGISTRY,
titles: TITLES,
});
return (
<>
<button onClick={saveLayout}>Save layout</button>
<MosaicLayout panels={panels} initialNode={initialNode} onNodeChange={onNodeChange} />
</>
);
}
MosaicLayoutreadsinitialNodeonly once (on mount). To apply a fresh tree afterresetLayout(), remount the subtree by bumping a Reactkey.
defineRegistry() — type-safe registries
PersistedLayoutRegistry<T> erases each entry's component prop types. Wrap your registry in defineRegistry() to keep them: componentProps is then checked against each entry's component, and the literal key/entry types are preserved.
import { defineRegistry } from '@heojeongbo/react-mosaic-ui';
const REGISTRY = defineRegistry({
chart: { component: Chart, componentProps: { series } }, // ✗ wrong-shaped componentProps now errors
table: { component: Table },
});usePanelState() — per-panel state that persists with the layout
Inside a panel rendered by a MosaicLayout wrapped in the PanelStateProvider, usePanelState() works like useState but the value is saved and restored with the layout. Outside a provider it transparently falls back to local component state.
const { PanelStateProvider, /* … */ } = usePersistedLayout({ storageKey, registry });
return (
<PanelStateProvider>
<MosaicLayout panels={panels} initialNode={initialNode} onNodeChange={onNodeChange} />
</PanelStateProvider>
);
// inside a panel component:
function TodoPanel() {
const [checked, setChecked] = usePanelState<Record<string, boolean>>({ defaultState: {}, version: 1 });
// … state is saved with saveLayout() and restored on reload
}API Reference
<Mosaic>
| Prop | Type | Default | Description |
|---|---|---|---|
renderTile |
(id: T, path: MosaicPath) => ReactNode |
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 |
resize |
ResizeOptions |
— | Override minimum pane size |
ariaLabel |
string |
"Mosaic layout" |
Accessible label for the container (role="group") |
highlightActive |
boolean |
true |
Highlight the focused window with a ring |
<MosaicWindow>
| Prop | Type | Default | Description |
|---|---|---|---|
title |
string |
required | Toolbar title (and default accessible label) |
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 |
toolbarControls |
ReactNode |
— | Extra controls rendered before the built-in toolbar buttons |
additionalControls |
ReactNode |
— | Extra controls shown in a collapsible drawer (⋯) |
renderToolbar |
(props, defaultToolbar) => ReactNode |
— | Full toolbar override (receives default as second arg); return null for no toolbar |
onDragStart |
() => void |
— | Called when drag begins |
onDragEnd |
(type: 'drop' | 'reset') => void |
— | Called when drag ends |
onError |
(error, action: 'split' | 'replace') => void |
— | Called when a Split/Replace createNode rejects or throws (otherwise logged via console.error) |
closable |
boolean |
true |
Show the Close (✕) button |
hideToolbar |
boolean |
false |
Render with no toolbar chrome at all |
className |
string |
— | Extra class on the window element |
bodyPadding |
string | number |
— | Inline padding override for the body (wins over CSS) |
bodyClassName |
string |
— | Extra class on the window body |
ariaLabel |
string |
title |
Accessible label for the window region |
panelId |
T |
— | Leaf id; supplied by MosaicLayout for panel-state persistence |
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 |
| Maximize | Maximizes the tile to fill the layout; click again to restore |
| 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
pruneTree, // Drop leaves not in a valid-id set, collapsing emptied splits
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 [existing, new]: createSplitUpdate(root, path, newNode)
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]));
// Drop tiles whose ids are no longer valid (e.g. when restoring a saved tree)
const valid = new Set(['editor', 'preview']);
const cleaned = pruneTree(tree, valid); // emptied splits collapse to the surviving sideContexts
For advanced use cases you can read the mosaic state from context using the typed convenience hooks:
import { useMosaicContext, useMosaicWindowContext } from '@heojeongbo/react-mosaic-ui';
// Inside a tile rendered by renderTile:
function MyTile() {
const { mosaicActions, mosaicId } = useMosaicContext();
const { mosaicWindowActions } = useMosaicWindowContext();
return (
<button onClick={() => mosaicActions.remove(mosaicWindowActions.getPath())}>
Close me
</button>
);
}useMosaicContext<T>() accepts an optional generic type parameter to type mosaicActions when your tile key is not string:
const { mosaicActions } = useMosaicContext<number>();If you prefer, you can access the raw contexts directly:
useContext(MosaicContext)/useContext(MosaicWindowContext).
MosaicRootActions (via useMosaicContext().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 |
add(node, position?) |
Add a new node to the layout |
maximize(path) |
Render only the tile at path, remembering the previous tree |
restore() |
Restore the tree captured by the last maximize() |
isMaximized() |
Whether a tile is currently maximized |
MosaicWindowActions (via useMosaicWindowContext().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';Theming with CSS variables
Override these on :root (or any ancestor of the mosaic) to theme the layout. No !important required.
:root {
/* Colors */
--rm-border-color: #cbd5e1;
--rm-background: #ffffff;
--rm-window-bg: #f8fafc;
--rm-toolbar-bg: #f1f5f9;
--rm-split-color: #94a3b8;
--rm-split-hover: #64748b;
/* Sizing / layout */
--rm-split-size: 4px; /* Width/height of the resize handle */
--rm-toolbar-height: 40px;
--rm-toolbar-padding: 0.5rem 1rem;
--rm-toolbar-border: 1px solid var(--rm-border-color);
--rm-window-radius: 0.25rem;
--rm-window-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
--rm-window-body-padding: 1rem; /* Inner padding of each window's content */
/* Active-window highlight (static ring, no animation) */
--rm-active-color: #3b82f6; /* Ring color of the focused window */
--rm-window-active-border: 0 0 0 2px var(--rm-active-color);
--rm-window-active-shadow: 0 0 0 4px rgb(59 130 246 / 0.18);
}Overriding with plain CSS (no !important)
Every element carries exactly one semantic class that owns all of its styling — .rm-mosaic-window, .rm-mosaic-window-toolbar, .rm-mosaic-window-title, .rm-mosaic-window-controls, .rm-mosaic-window-body, .rm-mosaic-button, .rm-mosaic-split, … Because none of them use !important, you can override any of them at normal specificity:
/* A flush, chrome-less look — no !important anywhere */
.rm-mosaic-window {
border-radius: 8px;
overflow: hidden;
}
.rm-mosaic-window-toolbar {
background: none;
height: auto;
border-bottom: none;
padding: 0;
}
.rm-mosaic-window-body {
padding: 0;
}You can also kill the body padding globally with just the variable:
:root { --rm-window-body-padding: 0; }All class names use the rm- prefix, so they won't collide with your own styles.
Headless / no-chrome windows
When you don't want the default toolbar or body padding at all, use props instead of CSS overrides:
| Prop | Type | Effect |
|---|---|---|
hideToolbar |
boolean |
Render the window with no toolbar chrome at all. |
bodyPadding |
string | number |
Inline padding override for the body (e.g. 0). Wins over CSS without !important. |
bodyClassName |
string |
Extra class on the body, for full control. |
<MosaicWindow title="Chart" path={path} hideToolbar bodyPadding={0}>
<Chart />
</MosaicWindow>These are also available per-panel on MosaicLayout / usePersistedLayout panel configs (hideToolbar, bodyPadding, bodyClassName). The renderToolbar={() => null} escape hatch still works if you prefer it.
Migrating to 5.x
createSplitUpdatenow takes the root and preserves the existing tile. The signature changed fromcreateSplitUpdate(path, newNode, direction?)tocreateSplitUpdate(root, path, newNode, direction?), and it splits the node atpathinto[existing, newNode]instead of placingnewNodeon both sides (which produced a duplicate leaf id). Pass the current tree as the first argument.- Low-level bounding-box helpers are no longer exported from the package root:
createBoundingBox,getWidth,getHeight,split,containsPoint. They were layout internals. TheBoundingBoxtype is still exported, so build one as a plain object ({ top, right, bottom, left }) if you ever pass one to<Split>directly.
Migrating from 2.x
- No more
!important. All styling moved onto single semantic classes with normal specificity. If you previously used!importantto fight the library's own!important, you can remove it — plain overrides now win. - Theming variables renamed to
--rm-*. The old--color-mosaic-*aliases were removed in 4.x; use the--rm-*names (--rm-border-color,--rm-window-bg,--rm-toolbar-bg,--rm-split-color,--rm-split-hover,--rm-background). - Internal utility classes removed.
rm-flex,rm-px-4,rm-bg-mosaic-toolbar, etc. are no longer instyles.css. They were never public; if you referenced them, target the semantic classes instead.
Active panel highlight
When you have several similar (or identical) panels, it can be hard to tell which one you're working in. The focused window is highlighted with a persistent ring (no animation). It's on by default and toggled imperatively, so it never triggers re-renders.
- Focusing a window (keyboard, programmatic, or clicking anywhere inside it) marks it active. Active state persists as "last focused", like an editor's active pane.
- Disable globally with
highlightActive={false}on<Mosaic>. - Theme with the
--rm-active-color,--rm-window-active-border, and--rm-window-active-shadowCSS variables.
// Default on:
<Mosaic value={tree} onChange={setTree} renderTile={renderTile} />
// Off:
<Mosaic value={tree} onChange={setTree} renderTile={renderTile} highlightActive={false} />Style hook: the active window gets .rm-mosaic-window--active.
Accessibility
The library ships sensible ARIA defaults:
- The mosaic container has
role="group"and anaria-label(default"Mosaic layout", override with theariaLabelprop on<Mosaic>). - Each window has
role="region"and anaria-labeldefaulting to itstitle(override per window with theariaLabelprop on<MosaicWindow>). - Resize handles have
role="separator",aria-orientation,aria-valuenow/min/max, are focusable (tabIndex={0}), and are keyboard operable: with the handle focused, Arrow keys nudge the split by 1% (10% with Shift), and Home/End jump to the minimum/maximum pane size. - Toolbar buttons carry
aria-labels matching their action (Split, Replace, Expand, Maximize, Close, More) and show a visible focus ring via:focus-visible(keyboard only) — nooutline: nonedead end. - The active-panel highlight is purely visual — a focused window's
role="region"is already announced, so no extra ARIA is added.
<Mosaic ariaLabel="Dashboard panes" renderTile={...} value={...} onChange={...} />
<MosaicWindow title="Sales" ariaLabel="Sales chart" path={path}>…</MosaicWindow>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>When extracting the toolbar into a separate component, use the exported MosaicWindowToolbarProps type:
import type { MosaicWindowToolbarProps } from '@heojeongbo/react-mosaic-ui';
function MyToolbar({ title, dragHandle }: MosaicWindowToolbarProps<string>) {
return (
<div ref={dragHandle.ref} style={{ display: 'flex', alignItems: 'center', padding: '0 8px' }}>
<span>{title}</span>
</div>
);
}
<MosaicWindow
title="My Window"
path={path}
renderToolbar={(props) => <MyToolbar {...props} />}
>
<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
bun run test
# Run tests with coverage (100% thresholds enforced)
bun run test:coverage
# Type check
bun run typecheck
# Lint
bun run lint
# Run all checks (lint + typecheck + tests)
bun run check
# Run the example app
cd example && bun install && bun run devTesting & coverage
The test suite runs on Vitest + Testing Library (jsdom).
vitest.config.ts enforces 100% coverage thresholds for statements, branches,
functions, and lines:
bun run test:coverageNotes on what 100% means here:
bun run checkruns lint + typecheck + tests; coverage thresholds are enforced separately bybun run test:coverage.- A small number of genuinely-unreachable defensive guards and drag-and-drop timing
branches are annotated with
/* v8 ignore */and excluded from the count. - Barrel
index.tsfiles, type-only modules, and the context default object are excluded from instrumentation (seecoverage.excludeinvitest.config.ts).
Release
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 |
| Plain CSS | Styling — rm- prefix, CSS-variable theming, no !important |
| React DnD | Drag and drop |
| Immer | Immutable tree updates |
| Vitest | Testing |
| Bun | Package manager & scripts |
License
MIT