Framework-agnostic pane layout library for IDE-style workspaces.
Apane gives you split panes, tab stacks, docking, runtime tab control, drag-and-drop, resize handling, and a full event surface without tying you to a UI framework.
- Recursive split and stack layout model
- Runtime tab add, update, move, activate, close, and toggle APIs
- Stack header control buttons with runtime registration
- Active-tab header controls that can override stack controls by
id - Dock tabs left, right, top, bottom, or reorder inside stacks
- Pointer-based split resizing with subtree-aware
minSize - Global tab parameters via
setGlobalParam(...) - Event system with
.on(),.off(),.once() - Flexible tab icons, dirty icons, and close icons
- Layout serialization and hydration
npm installnpm run devnpm run buildimport { createApane, Node, Stack, Tab } from "apane";
import "apane/style.css";
const pane = createApane(document.getElementById("app"), {
minSize: 0.16,
layout: new Node({
id: "root",
type: "stack",
minSize: 0.2,
stack: new Stack("main", [
new Tab("welcome", "Welcome", "welcome")
])
}),
contents: {
welcome: () => "<div>Hello</div>"
}
});Represents either:
- a
splitnode withdirectionandchildren - a
stacknode with aStack
Example:
new Node({
id: "workspace",
type: "split",
direction: "row",
children: [
new Node({
id: "left",
type: "stack",
stack: new Stack("left-stack", [
new Tab("files", "Files", "files")
])
}),
new Node({
id: "right",
type: "stack",
stack: new Stack("right-stack", [
new Tab("editor", "index.js", "editor")
])
})
]
});Owns an ordered list of tabs and the activeTabId.
Represents a single tab.
new Tab("editor", "index.js", "editor", "./icons/js.svg", true, {
closable: true,
closeIcon: "<span>×</span>",
isDirtyIcon: "./icons/dirty.svg",
meta: { language: "javascript" }
});const pane = createApane(element, {
layout,
contents,
minSize: 0.12,
className: "custom-root",
globalTabParams: {
closable: true
}
});layout: required rootNodecontents: object of content renderersminSize: default minimum normalized size for resizable nodesclassName: extra class added to the root elementglobalTabParams: properties applied to all tabs
Each renderer receives:
{
tab,
container,
layout,
stack,
node
}You can return:
- an HTML string
- a DOM node
- an array of DOM nodes
- or render directly into
container
Example:
const pane = createApane(root, {
layout,
contents: {
editor: ({ tab }) => `<pre>Opened: ${tab.title}</pre>`
}
});If a content key is missing, Apane leaves the content area blank and logs a warning in the console.
getNode(nodeId)getNodes()getStack(stackId)getStacks()getTab(tabId)getTabs()
setLayout(root)updateLayout((root, layout) => nextRoot)setFillRoot(stackId, enabled)isFillRootActive()getFillRootState()serialize()toJSON()render()destroy()
registerContent(key, renderer)registerContents(renderers)
addTab(stackId, tab, options?)insertTab(stackId, tab, index, options?)activateTab(tabId)closeTab(tabId)closeTabs(tabIds)moveTab(tabId, targetNodeId, options?)updateTab(tabId, patch)setTabTitle(tabId, title)setTabIcon(tabId, icon)setTabCloseIcon(tabId, closeIcon)setTabDirtyIcon(tabId, isDirtyIcon)setTabDirty(tabId, isDirty)toggleTabDirty(tabId)setTabClosable(tabId, closable)toggleTabClosable(tabId)
setStackActiveTab(stackId, tabId)setFillRoot(stackId, enabled)addStackHeaderControl(config)updateStackHeaderControl(id, patch, stackId?)removeStackHeaderControl(id, stackId?)getStackHeaderControls(stackId?)addTabHeaderControl(config)updateTabHeaderControl(id, patch, tabId)removeTabHeaderControl(id, tabId)getTabHeaderControls(tabId?)getResolvedHeaderControls(stackId?, activeTabId?)
setGlobalParam(paramName, value)applyGlobalParams()
setGlobalParam(...) updates all current tabs immediately and is also applied to tabs added later.
Example:
pane.setGlobalParam("closeIcon", "./icons/close.svg");
pane.setGlobalParam("closable", false);
pane.setGlobalParam("meta", { source: "runtime" });To remove a global param, pass undefined:
pane.setGlobalParam("closeIcon", undefined);icon, closeIcon, and isDirtyIcon support:
- plain text
- image URLs
- local/relative asset paths
- HTML markup strings
- DOM elements
Examples:
new Tab("a", "App", "app", "JS");
new Tab("b", "Image", "image", "/icons/file.svg");
new Tab("c", "HTML", "html", "<strong>H</strong>");
new Tab("d", "DOM", "dom", document.createElement("span"));If isDirty is true, the dirty indicator lives in the close-icon slot.
You can supply your own dirty-state icon with isDirtyIcon.
Behavior:
- normal state: dirty dot visible
- hover: close icon visible
- drag preview: dirty dot visible
Runtime helpers:
pane.setTabDirty("editor", true);
pane.toggleTabDirty("editor");
pane.setTabDirtyIcon("editor", "./icons/dirty.svg");Apane includes a built-in event bus:
on(eventName, listener)off(eventName, listener)once(eventName, listener)
Example:
const unsubscribe = pane.on("tab:add", (event) => {
console.log("added", event.tab.id);
});
pane.once("tab:close", (event) => {
console.log("closed", event.tabId);
});
unsubscribe();render:beforerendercontent:registerlayout:setlayout:updatelayout:fill-rootglobal:paramtab:addtab:activatetab:updatetab:movetab:closestack:changestack:control:addstack:control:updatestack:control:removestack:control:clicktab:control:addtab:control:updatetab:control:removetab:control:click
All events include at least:
typelayout
Common payload fields:
tabtabIdstacknoderootpreviouspatchparamNamevalueplacementindexreason
You can also listen to "*" if you want a catch-all channel:
pane.on("*", (event) => {
console.log(event.type, event);
});const closeAllControl = pane.addStackHeaderControl({
id: "close-all",
title: "Close all tabs",
icon: "<span>×</span>",
stackId: "main-stack",
onClick: ({ stack, layout }) => {
if (!stack) return;
layout.closeTabs(stack.tabs.map((tab) => tab.id));
}
});The returned handle supports:
closeAllControl.update({
title: "Close every tab"
});
closeAllControl.remove();pane.addTabHeaderControl({
id: "close-all",
title: "Close only this tab's stack",
icon: "./icons/close-all.svg",
tabId: "editor",
onClick: ({ stack, layout }) => {
if (!stack) return;
layout.closeTabs(stack.tabs.map((tab) => tab.id));
}
});If a tab control uses the same id as a stack control, the tab control overrides it only while that tab is active.
pane.addTab("main-stack", new Tab("logs", "Logs", "logs"));pane.setFillRoot("editor-a", true);Restore the previous layout:
pane.setFillRoot("editor-a", false);Or inspect current state:
pane.isFillRootActive();
pane.getFillRootState();pane.insertTab("main-stack", new Tab("preview", "Preview", "preview"), 1);pane.updateTab("editor", {
title: "index.ts",
isDirty: true
});pane.moveTab("editor", "secondary-node", {
placement: "center",
index: 0
});pane.moveTab("editor", "target-node", {
placement: "right"
});pane.setLayout(new Node({
id: "root",
type: "stack",
stack: new Stack("single", [
new Tab("welcome", "Welcome", "welcome")
])
}));pane.updateLayout((root) => {
root.size = 1;
return root;
});const json = pane.serialize();const snapshot = pane.toJSON();And restore:
import { ApaneLayout } from "apane";
const restored = ApaneLayout.fromJSON(element, snapshot, {
contents
});minSizecan be set globally increateApane({ minSize })and overridden perNode- empty stacks render blank content
- missing renderers do not render fallback UI
- drag-and-drop, stack ordering, close icons, and dirty indicators work at runtime
createApaneApaneLayoutNodeStackTabgetVersion