React 19 · 8 kB gzipped · no dependencies

The chassis
under your tool.

Icon rails on the edges, resizable zones around a centre that is yours, and a layout that survives a reload. Headless underneath, repaintable on top.

See the examples
Live. Drag the gutters between the surfaces, click a rail icon or move it into another half, resize the window.

Four ways in

Each one runs in your browser and its whole source is on screen. Start from the closest.

Minimal

The smallest chassis that works. Two columns, a band, a centre — and a header of your own driving it.

import { Panels, Panel } from '@pasquelin/panels'
import '@pasquelin/panels/styles.css'

<Panels<PanelId>>
  <Panel<PanelId> id="files" zone="left" title="Files">
    <FileTree />
  </Panel>

  <Panel<PanelId> id="notes" zone="right" opens={380}>
    <Notes />
  </Panel>

  <Panels.Center>
    <YourApp />
  </Panels.Center>
</Panels>

Tip. Panels sharing a zone and a slot take turns; the rail switches between them. Give a panel the secondary slot to stack it under the first instead. Here the rail icons can also be dragged from one half to another.

Open Minimal

React Router

The centre is an outlet. Navigating changes the middle and nothing else — columns keep their width, open panels stay open.

function Layout() {
  return (
    <Panels<PanelId>>
      <Panel<PanelId> id="sites" zone="left" title="Sites">
        <SiteList />
      </Panel>

      <Panels.Center>
        <Outlet />
      </Panels.Center>
    </Panels>
  )
}

Tip. Declare the panels in the layout route, above the outlet. Declared per page, they would unmount on every navigation and lose whatever they held.

Open React Router

Document tabs

The centre carries documents on Dockview — draggable, splittable tabs — while panels stay on the edges.

import { DockviewCenter } from '@pasquelin/panels/dockview'

<Panels.Center>
  <DockviewCenter
    documents={{ editor: Editor }}
    empty={NothingOpen}
    onReady={setApi}
  />
</Panels.Center>

Tip. Import it from the dockview entry point so its weight only lands on the projects that want tabs. Tool panels never enter the centre: a document has a name, a panel has an icon.

Open Document tabs

Repainted

The same chassis under four palettes. Colours, radius, rail width, header height — all custom properties.

.pnl-root {
  --pnl-chassis: #2e2b26;
  --pnl-panel: #1b1916;
  --pnl-accent: #d99a2b;
  --pnl-radius: 12px;
  --pnl-rail: 60px;
}

Tip. Set the accent token on any ancestor and the chassis inherits your brand instead of imposing one. Need to go further? Every piece is exported and replaceable on its own.

Open Repainted

The whole surface

There is not much of it, and that is the point. Six things to know.

Declare

A panel is a descriptor. Where it hangs, what it is called, what it draws.

<Panel id="files" zone="left" title="Files" icon={<Icon />}>
  <FileTree />
</Panel>
Drive

One hook for every header, shortcut or menu that needs to act on the panels.

const { reveal, close, toggle, isShown } = usePanels<PanelId>()
Rearrange

One prop, and a reader drags a rail icon into another half. Where they put it is remembered with the rest of the layout.

<Panels<PanelId> draggablePanels>
Drive from outside React

Build the store yourself and a socket, a native menu or a worker can open a panel.

const store = createPanelsStore<PanelId>()
socket.on('alert', () => store.getState().show('alerts'))
Repaint

Every value is a custom property. Set the accent and the rails follow your brand.

.pnl-root {
  --pnl-panel: #101418;
  --pnl-accent: #47965c;
  --pnl-radius: 10px;
}
Or take the logic only

The components are built on hooks that render nothing. Draw your own frame on them.

const zone = useZone('left')
useContainerFit(ref)