Headless, resizable, tabbed, splittable layout primitives for React. Build IDE-style multi-pane UIs with nested splits, tabs per pane, drag-resize, collapse, and full-screen maximize — without prescribing any styling.
Documentation · Live examples · npm
npm install react-splitkitPeer dependency: react >= 18.
import{LayoutProvider,LayoutRoot,TabList,TabPanel,createPanel,createSplit,typeTabRegistry,typeRenderPanelProps,}from'react-splitkit';constregistry: TabRegistry={editor: {tabType: 'editor',title: 'Editor',render: (tab)=><div>Editor: {tab.id}</div>,},console: {tabType: 'console',title: 'Console',render: ()=><div>Console output…</div>,},};constinitialLayout=createSplit('root','horizontal',[createPanel('p1',[{id: 't1',tabType: 'editor',title: 'main.ts'}]),createPanel('p2',[{id: 't2',tabType: 'console',title: 'Output'}]),]);constPanelChrome=({ panel, style }: RenderPanelProps)=>(<divstyle={{ ...style,display: 'flex',flexDirection: 'column'}}><TabListpanelId={panel.id}renderTab={({ tab, isActive, tabProps, label })=>(<button{...tabProps}style={{fontWeight: isActive ? 600 : 400}}>{label}</button>)}/><TabPanelpanelId={panel.id}/></div>);exportconstApp=()=>(<LayoutProviderinitialLayout={initialLayout}registry={registry}><LayoutRootrenderPanel={PanelChrome}/></LayoutProvider>);The components ship headless. To opt in to a sensible default look (light + dark mode aware):
import'react-splitkit/styles.css';Defaults are wrapped in @layer splitkit, so any unlayered consumer rule wins without specificity battles.
Theme via CSS custom properties on [data-splitkit-root]:
[data-splitkit-root] {
--sk-bg:#0a0a0a;
--sk-accent:#ec4899;
--sk-border:rgba(255,255,255,0.08);
}Available variables: --sk-bg, --sk-border, --sk-tab-bg, --sk-tab-bg-active, --sk-tab-fg, --sk-tab-fg-active, --sk-accent, --sk-resizer-bg, --sk-resizer-bg-hover, --sk-resizer-bg-active, --sk-radius, --sk-overlay-shadow.
For fully custom styling, target the data-attributes directly: [data-splitkit-root], [data-splitkit-split], [data-splitkit-cell], [data-splitkit-resizer], [data-splitkit-maximized-overlay], [data-panel-tablist], [data-panel-tabpanel].
| Export | Purpose |
|---|---|
LayoutProvider | Wraps your layout. Creates an isolated store + registry. |
createLayoutStore | Vanilla Zustand store factory (advanced). |
useLayoutContext | Access the underlying store + registry. |
LayoutProvider props:
interfaceLayoutProviderProps{initialLayout: LayoutNode;registry: TabRegistry;onChange?: (layout: LayoutNode,action: LayoutAction)=>void;generateId?: IdGenerator;}| Export | Purpose |
|---|---|
LayoutRoot | Recursively renders the tree. Accepts renderPanel and optional renderResizer. |
Resizer | Default interactive resizer with ARIA + touch hit area. |
TabList | Headless tab list. Consumer renders each tab via renderTab. |
TabPanel | Renders the active tab's content via the registry. |
TabAddMenu | Optional "+" menu for adding registered tab types. |
| Hook | Returns |
|---|---|
useLayout() | { layout, dispatch } |
usePanel(panelId) | Panel state + bound actions: setActiveTab, addTab, removeTab, reorderTab, split, toggleCollapse, toggleMaximize. |
useSplit(splitId) | { split, setSizes } |
useTabRegistry() | The full registry. |
useTabRegistryEntry(tabType) | A single registry entry. |
useResize(options) | Low-level pointer + keyboard resize logic. |
usePanelMinMax(panelId) | Effective minSize / maxSize for a panel. |
useLayoutBreakpoint() | Responsive breakpoint helper. |
| Export | Purpose |
|---|---|
createPanel(id, tabs?) | Build a PanelNode. |
createSplit(id, direction, children, sizes?) | Build a SplitNode. |
findNode, findPanel, findTab | Tree lookups. |
replaceNode(tree, id, replacement) | Immutable replace. |
normalize(tree) | Collapse single-child splits and flatten same-direction nesting. |
layoutReducer(state, action, ctx?) | Pure reducer for every LayoutAction. |
createId, resetIdCounter | Deterministic ID generation. |
Define one entry per tabType:
constregistry: TabRegistry={editor: {tabType: 'editor',title: 'Editor',minSize: 30,// % min for any panel hosting this tabmaxSize: 80,// % maxclosable: true,render: (tab,ctx)=><Editorfile={tab.id}/>,renderLabel: (tab)=><span>{tab.title}</span>,},};Per-tab descriptor overrides win over the registry entry.
Dispatch via useLayout().dispatch(action):
REPLACE_LAYOUTSPLIT_PANEL— split intotargetof'left' | 'right' | 'top' | 'bottom'ADD_TAB,REMOVE_TAB,MOVE_TAB,REORDER_TAB,SET_ACTIVE_TABRESIZE_SPLITTOGGLE_COLLAPSE,TOGGLE_MAXIMIZE
minSize / maxSize (percentages) can be set on a panel descriptor and on a tab registry entry. The effective min is max(panelMin, max of all tab mins); the effective max is the symmetric min. The default Resizer enforces these during drag and keyboard resize.
- Tabs: ←/→ or ↑/↓ navigate, Home/End jump, Delete closes (when closable).
- Resizer: Alt+Arrow nudges by 5% (Shift+Alt+Arrow = 10%). Enter toggles a sticky resize mode where plain arrows resize. Escape exits.
Render multiple <LayoutProvider>s on the same page. Each owns an independent store; ids are auto-prefixed per provider so DOM ids stay unique.
The tree is plain JSON. Persist via onChange and rehydrate by passing the serialized tree as initialLayout:
<LayoutProviderinitialLayout={savedLayout??initialLayout}registry={registry}onChange={(layout)=>localStorage.setItem('layout',JSON.stringify(layout))}/>The docs and demo site live in web/ and are built with Next.js.
npm install # installs everything (workspaces)cd web
npm run dev # http://localhost:3000Component playgrounds live under stories/. Each story exposes interactive controls (layout preset, maximizeMode, custom theme toggle) via the Storybook Controls panel.
npm run storybook # dev server on http://localhost:6006
npm run build-storybook # static build into storybook-static/Bug reports and feature requests are welcome — please open an issue on GitHub. For pull requests, open an issue first so we can discuss the approach.
MIT © Amaresh S M