biombo
v0.7.0
Published
Headless panel and window management for React: z-order, focus, Escape stack, docking, minimize and persistence.
Downloads
109
Maintainers
Readme
biombo
Headless panel and window management for React: z-order, focus, Escape stack, docking, minimize and persistence.

biombo owns the state — stacking, focus, Escape, docking, minimize, tabs, persistence — and you own every
pixel: it ships no styles, only state, events, data-*/ARIA attributes and the functional inline style
(position, left/top/width/height, z-index). Docked panels shrink the usable area it reports, so your
own content (a map, an editor) stays visible next to them.
Install
npm install biombo| Entry | What it is | Needs |
|---|---|---|
| biombo | Framework-free core: manager, z-order bands, Escape stack, layout, persistence, anchoring | nothing |
| biombo/react | BiomboProvider, usePanel and the layout, tabs and surface hooks; re-exports the core | react >= 19; react-dom >= 19 for Portal |
Quick start
import { BiomboProvider, useArea, usePanel } from 'biombo/react';
function Table() {
const panel = usePanel('table', { dockable: ['bottom'], initial: { open: true } });
return <section {...panel.getWindowProps()}><header {...panel.getHandleProps()}>Drag me to the bottom edge</header></section>;
}
function Map() {
const { top, right, bottom, left } = useArea();
return <main style={{ position: 'fixed', inset: `${top}px ${right}px ${bottom}px ${left}px` }} />;
}
export const App = () => <BiomboProvider><Map /><Table /></BiomboProvider>;Core
import { createManager, localStorageAdapter } from 'biombo';
const manager = createManager({
edges: { right: { policy: 'tabs' } },
persist: { storage: localStorageAdapter(), key: 'app.panels', version: 1 },
onEvent: (event) => sync(event),
});
manager.register('layers', { dockable: ['left', 'right'] });
manager.subscribe(() => render(manager.getSnapshot()));
manager.open('layers');
manager.dock('layers', 'right');
manager.setDockSize('layers', 320);BiomboProvider creates a manager from options, or takes yours through manager.
Stacking and focus
Every panel lives in a named band; biombo orders panels inside each band and the band sets the base
z-index. Opening or pressing a panel brings it to the front of its band and marks it active.
The default bands and their base: overlay 5, docked 20, floating 30, menu 50 and modal 60.
Pass bands to replace them and band per panel to choose one. A group with mode: 'single' shows
one panel at a time; free (the default) lets them overlap.
Escape stack
One Escape closes only the topmost layer. Layers are ordered by priority, then by recency:
| Priority | Value | For |
|---|---|---|
| ESCAPE_PRIORITY.mode | 0 | Global modes, which close last (useGlobalMode) |
| ESCAPE_PRIORITY.surface | 100 | Panels (the default escapePriority) |
| ESCAPE_PRIORITY.menu | 200 | Menus and popovers |
| ESCAPE_PRIORITY.modal | 300 | Modals |
The provider listens on window in the capture phase and stops the event it handles. Pass
listenEscape={false} and call manager.escape.handle(event) to route it yourself.
Docking and the usable area
A panel docks to the edges listed in dockable. Each edge has a policy for a second panel:
| Policy | Behavior |
|---|---|
| replace | The new panel takes the edge; the previous one floats again (the default) |
| stack | Panels share the edge, one after the other |
| tabs | One panel shows at a time; useEdge and useTabs give the strip |
Docked sizes start at 40 % of the viewport, never below 120 px nor above 80 %
(defaultDockRatio, minDockSize, maxDockRatio). The snapshot keeps the usable area left by the
docked panels; useArea reads it and onAreaChange reports it. When the browser resizes, biombo clamps
floating rects and docked sizes again. dock(id, null) floats the panel back at its last rect; if it
never floated, it is centered in the area with its defaultSize, or with half the viewport capped by
its docked size.
Drag, snap and resize
getHandleProps drags with left/top written in a requestAnimationFrame and saves on release; links,
buttons, form fields and [data-biombo-nodrag] inside the handle do not start a drag. With snap set
to auto a panel docks when the pointer reaches an edge it can dock to, and through dock();
with button, only through dock(). useSnapPreview gives the rect to draw while dragging.
undockOnDrag (true or a distance in px) lets a docked panel float again when dragged.
getResizerProps(direction) resizes floating panels by side or corner and docked panels along their edge.
Initial position and height
manager.register('legend', {
initial: { open: true, rect: { anchor: 'bottom-right', x: 16, y: 16, width: 260, height: 200 } },
});
manager.fitHeight('legend', contentHeight, { maxRatio: 0.6 });initial.rect takes a plain rect or one anchored to a corner (top-left, top-right, bottom-left,
bottom-right): x and y (0 by default) are the distances from that corner's edges of the usable
area. It is resolved once, when the panel opens with a known viewport; a stored rect wins, and after
that it is a plain rect that the user moves.
fitHeight(id, height, options) sets a floating panel's height, clamped between min (and the panel's
minSize) and the lowest of max px and maxRatio of the viewport height. It keeps the top edge, or
the bottom one when the panel was anchored to a bottom corner (grow: 'down' | 'up' overrides it),
reports resize, and does nothing on docked or maximized panels or when nothing changes.
Presentation
state.presentation says how to draw the panel: hidden, floating, docked, maximized, or the
panel's compact mode (sheet, fullscreen, inline) below compactBreakpoint. Minimizing only
keeps the state: draw the pill yourself from the panels that are open and minimized (in React,
getRestoreProps gives its button). contain: 'full'
keeps a floating panel fully on screen instead of partially, containMargin px from the edges.
Persistence and events
persist saves minimized, dock, dockSize and rect by default (list open in fields to
keep it too), under a key and a version; migrate upgrades older data. serialize and hydrate
let you store the layout elsewhere. onEvent reports open, close, focus, minimize, restore, maximize, unmaximize,
dock, undock, resize, move and reorder, each with id and group.
React
import { BiomboProvider, localStorageAdapter, usePanel } from 'biombo/react';
function Table() {
const panel = usePanel('table', { dockable: ['bottom'], defaultSize: { width: 520, height: 280 } });
return (
<>
<button onClick={() => panel.toggle()}>Table</button>
<section {...panel.getWindowProps()} className="your-styles">
<header {...panel.getHandleProps()}>Drag me</header>
<div {...panel.getResizerProps()} />
</section>
</>
);
}
const options = { persist: { storage: localStorageAdapter(), key: 'app.panels', version: 1 } };
export const App = () => <BiomboProvider options={options}><Table /></BiomboProvider>;| Hook | For |
|---|---|
| usePanel | State, actions and prop getters of one panel: getWindowProps, getHandleProps, getResizerProps(direction?), getRestoreProps |
| useAutoHeight | Ref for the scrolling body of a panel whose height follows its content |
| useEdge, useTabs | The panels of an edge and its tab strip, with keyboard and drag reorder (onReorder), controlled or not |
| useCompact | Whether compact mode is on, below compactBreakpoint |
| useClearance, useObstacle | Offset that keeps a floating control clear of panels and registered obstacles |
function Legend() {
const panel = usePanel('legend', { minimizable: true, initial: { open: true, rect: { anchor: 'bottom-right', x: 16, y: 16, width: 260, height: 200 } } });
const body = useAutoHeight('legend', { maxRatio: 0.6 });
return (
<>
<section {...panel.getWindowProps()}>
<header {...panel.getHandleProps()}>Legend</header>
<div ref={body} style={{ overflow: 'auto' }}>…</div>
</section>
<button {...panel.getRestoreProps()}>Legend</button>
</>
);
}useAutoHeight(id, options) measures the window's chrome plus the natural height of the body's
children (in-flow only, with margins, padding and gap) and calls fitHeight when the body, its
children or the list of children change, and after a drag or resize by hand ends. enabled: false
stops it; neededHeight(body) is the same measure for your own code. getRestoreProps(input?) is a
button that restores the minimized panel and is hidden otherwise, so it can stay mounted.
F6 (cycleKey) moves focus between open panels, and closing a panel returns focus to the element that
opened it.
Surfaces
These hooks work with or without BiomboProvider:
| Hook | Does |
|---|---|
| useFocusTrap | Focus trap with stacked traps, inert for the rest of the page and focus returned on close |
| useScrollLock | Body scroll lock that survives nested modals |
| useDismissOutside | Dismiss on a press outside, topmost surface only |
| useAnchoredPosition, placeAnchored | Anchored to an element or a point, flipping and clamped to the viewport; over centers on it |
| useDraggable | Drag any element inside the viewport, alone or on top of an anchored surface |
| useDismissGesture | Swipe to dismiss, driven by CSS variables instead of re-renders |
| Portal | Render into document.body, SSR-safe |
A modal combines four of them:
import { ESCAPE_PRIORITY, Portal, useDismissOutside, useEscape, useFocusTrap, useScrollLock } from 'biombo/react';
function Modal({ onClose, children }) {
const ref = useRef(null);
const { getTrapProps } = useFocusTrap(ref);
const { getDismissProps } = useDismissOutside(ref, onClose);
useScrollLock();
useEscape(onClose, { priority: ESCAPE_PRIORITY.modal });
return (
<Portal>
<div ref={ref} {...getTrapProps()} {...getDismissProps()} role="dialog" aria-modal="true">{children}</div>
</Portal>
);
}A tooltip anchors to its trigger; an info box anchors to a map click and can be dragged away:
const anchored = useAnchoredPosition(tipRef, { anchor: triggerRef, open, placement: 'top' });
<div ref={tipRef} {...anchored.getFloatingProps()} role="tooltip" />
const drag = useDraggable(boxRef, { margin: 10 });
const anchored = useAnchoredPosition(boxRef, { anchor: { x, y }, shift: drag.offset, paused: drag.dragging });
<div ref={boxRef} {...anchored.getFloatingProps()}><header {...drag.getHandleProps()} /></div>useDismissGesture writes --biombo-swipe (px) and --biombo-swipe-progress (0–1) on the element and sets
data-swipe to idle, moving, armed or dismissed; the animation is yours:
.card { transform: translateX(var(--biombo-swipe, 0px)); opacity: calc(1 - var(--biombo-swipe-progress, 0) * 0.6); }
.card[data-swipe='idle'], .card[data-swipe='dismissed'] { transition: transform 0.22s, opacity 0.22s; }Notes:
position: fixedis relative to the nearest ancestor withtransform,filterorcontain, not to the viewport. Render anchored surfaces throughPortal(this needsreact-dom, an optional peer).- In a registered panel, pass
returnFocus: falsetouseFocusTrap: closing the panel already returns focus to its trigger. useAnchoredPositionowns the inlinemax-heightandmax-widthof the surface.- A surface rendered through a portal inside another one counts as inside it for
useDismissOutsidewhen the outer surface spreadsgetDismissProps().
Known limitations
- Panels move and resize with the pointer only; keyboard move and resize are planned after 1.0.
- In compact mode there is no drag, snap, undock or resizer. The dock is kept and applies again above the breakpoint.
- A hidden panel gets
hiddenanddisplay: noneinline, so your owndisplayon it does not apply while it is closed. - Without a provider,
useEscapeuses a global stack. If the provider and layers outside it share a page, the listener registered first wins: putBiomboProviderat the root. - There is no fully controlled
stateprop: sync throughserialize,hydrate,onEventand, for tabs,onReorder.
Development
npm test
npm run lint
npm run typecheck
npm run demo
npm run e2eThe demo is a playground over a fake map: five panels that float, dock to every edge with stack and
tabs policies, minimize to a pill bar and report the usable area, plus a modal, tooltips and an info box
anchored to map clicks that can be dragged or swiped away. The e2e suite runs Playwright against it.
Contributing
Issues are welcome. Open one before sending a pull request.
License
AGPL-3.0-only © GHEgar
