Documentation
Guides

Borders

Side bars with tabs that open panels beside or over the layout - docked and overlay borders, auto-hide, resizing and edge docking.

A border is a strip of tabs on one side of the layout, like an IDE's side bars. Its selected tab opens a panel next to the strip; clicking the selected tab again closes it. Borders are part of the model, so they are saved, restored and changed through commands like everything else.

Borders
Gallery

1. Declare them in the model

Borders live in the layout's borders, one per side at most:

import type { LayoutJson } from "@fragiola/dockable-react";

type Types = {
    tabs: {
        explorer: undefined;
        search: undefined;
        terminal: undefined;
    };
};

const json: LayoutJson<Types> = {
    version: 1,
    defaults: { border: { size: 220 } }, // every border's panel size
    borders: [
        {
            location: "start",
            selected: 0, // open on its first tab; -1 (the default) is closed
            children: [
                { id: "explorer", component: "explorer", label: "Explorer" },
                { id: "search", component: "search", label: "Search" },
            ],
        },
        {
            location: "bottom",
            size: 160,
            children: [{ id: "terminal", component: "terminal", label: "Terminal" }],
        },
    ],
    root: { type: "row", children: [/* … */] },
};

A border's tabs are ordinary tabs: Dockable.Panels renders their panels like any other, and their content keeps its state when they move between a border and a tabset. Its location is top, bottom, start or end: start is the side a line of text begins on (the left in LTR, the right in RTL). A border's id is border_<location> (border_start) unless you give one. The fields are in the JSON model reference: size, minSize, maxSize, show, autoHide, mode, droppable, autoSelectTabWhenOpen, autoSelectTabWhenClosed, and data (typed by the registry's border entry). Each behaviour field falls back to defaults.border.

2. Render the frame, the strips and the panels

Dockable.Borders replaces the root Dockable.Row inside Dockable.Root, and takes the row as its child. It lays the borders out around the main area, as FlexLayout does: the top and bottom strips span the full width, the start and end strips sit between them, and each panel area sits between its strip and the layout.

<Dockable.Root model={model}>
    <Dockable.Borders<Types>
        renderBar={(border) => (
            <Dockable.Border node={border} className="strip">
                <Dockable.TabList<Types> aria-label={`${border.location} panels`}>
                    {(tab) => (
                        <Dockable.Tab node={tab} className="border-tab">
                            {tab.label}
                        </Dockable.Tab>
                    )}
                </Dockable.TabList>
            </Dockable.Border>
        )}
    >
        <Dockable.Row<Types>>{renderNode}</Dockable.Row>
    </Dockable.Borders>
    <Dockable.Panels<Types>>{(tab) => <Dockable.Panel node={tab}>{content(tab)}</Dockable.Panel>}</Dockable.Panels>
</Dockable.Root>
  • renderBar renders a border's strip (it receives the BorderNode): a Dockable.Border with a Dockable.TabList. The tab list runs vertically in a start or end border (aria-orientation, arrow keys).

  • renderContent (optional) renders the panel area: by default a Dockable.BorderContent, which holds an area sized by the border (the engine positions the selected tab's panel over it) and the border's splitter on the layout's side. Pass your own to style it, or to replace the splitter:

    renderContent={(border) => (
        <Dockable.BorderContent
            node={border}
            renderSplitter={(node) => <Dockable.Splitter node={node} aria-label="Resize" className="splitter" />}
        />
    )}

Borders only render in the main layout, never in a popout window.

3. Style the strips

The primitives set only structural flex. Everything else is yours, through data-*:

onattributemeaning
Border, BorderContentdata-locationtop, bottom, start or end
Border, BorderContentdata-orientationvertical for a start or end border (the direction its tabs run)
Border, BorderContentdata-opena tab is selected: the panel is open
Border, BorderContentdata-overlay / data-dockedan overlay border / a docked border
Border, BorderContentdata-emptythe border has no tabs
Borderdata-tab-directionon the start border: up (default) or down, from the tabDirection prop
Borderdata-drop-targetthe current drag would drop into the border
Borderdata-drop-refusedthe current drag is over the border, and a rule refuses it

Side labels are the classic question. Turn them with writing-mode (the tabs stay in order from the top), and turn the start border's half a turn more when it reads "up", FlexLayout's default:

[data-location][data-orientation="vertical"] [role="tab"] {
    writing-mode: vertical-rl;
}
[data-tab-direction="up"] [role="tab"] {
    transform: rotate(180deg);
}

Which way the start border reads is a prop of the strip, not a model field: tabDirection="down" on Dockable.Border sets data-tab-direction="down". The attribute is what the prop asks for; the page's direction does not change it. In a right-to-left page the start border is on the right, where labels usually read down: pass tabDirection="down" there, so the attribute says what is drawn and the same rule turns the labels in both directions (Right-to-left layouts).

With Tailwind, as the examples do: in-data-[orientation=vertical]:[writing-mode:vertical-rl] in-data-[tab-direction=up]:rotate-180 on the tab. Drops into the strip are hit-tested against the tab buttons' measured boxes, so any styling works.

Tab orientation is styling

Nothing in the package turns a side border's labels: Dockable.Border lays its tab list out as a column and exposes data-orientation and data-tab-direction. Vertical labels, upright labels in a column, or icon-only tabs as in an IDE's activity bar are all class names, and the icon comes from your data:

// an activity bar: an upright icon per tab, named for assistive technology
<Dockable.Tab node={tab} aria-label={tab.label} className="grid place-items-center p-2">
    <Icon name={tab.component} aria-hidden />
</Dockable.Tab>

An icon-only tab has no text: give it an aria-label (and a tooltip for sighted users). The border tab orientation example switches the same borders between vertical and upright labels, and the IDE workbench has an activity bar.

Border tab orientation
Gallery

4. Open, close and resize

  • Click a tab to open its panel (tab.select); click the selected tab again to close it (border.configure with open: false). Both are commands, so your middleware sees them.
  • Resize with the border's splitter: drag it, or focus it and use the arrow keys (10px a step, towards the layout). Its ARIA value is the size in px (aria-valuenow, aria-valuemin, aria-valuemax from size, minSize, maxSize). The command is border.resize, run as transient while a live drag moves and once more on release.
  • From code, with the border's id:
model.run("tab.select", { tabId: "search" }); // opens the start border on Search
model.run("border.configure", { borderId: "border_start", open: false }); // closes its panel
model.run("border.configure", { borderId: "border_start", open: true }); // reopens it (first tab if none)
model.run("border.resize", { borderId: "border_start", size: 300 }); // clamped to minSize/maxSize
model.run("border.configure", { borderId: "border_bottom", show: false }); // hides the whole border

border.configure also changes mode, autoHide, droppable, the sizes and data; a null value removes the border's own value so defaults.border applies. Opening a border that has no tabs is refused.

5. Drag tabs in and out

Borders take part in drag and drop:

  • a tab dropped on a strip joins the border at that position;
  • a tab dropped on an open panel joins the border (appended);
  • a border's tab dragged into a tabset (or to a layout edge) leaves the border.

A border takes tabs only (never a whole tabset) and never a pinned tab. droppable: false on a border (or defaults.border.droppable) refuses drops into it, and a middleware sees border targets like any other (to is the border's id; see Restricting drops). Dockable.Border carries data-drop-target while it is the target, and data-drop-refused while a rule refuses it.

Overlay borders

Overlay borders
Gallery

An overlay border (mode: "overlay") opens over the layout instead of beside it, like a drawer:

{ location: "start", mode: "overlay", children: [/* … */] }

Switch one at runtime with border.configure:

model.run("border.configure", { borderId: "border_start", mode: "overlay" }); // or "docked"

model.get("border-settings-by", { borderId })?.mode reads the mode in effect (the border's own, else the default), and model.is("border-overlay", { borderId }) answers the same as a boolean.

  • It closes on a press in the layout outside its panel (a tabset, a splitter), and on Escape (keyMap.closeOverlayBorder) with focus in the panel or on its tab button; focus goes back to the tab button. A press on a border strip does not close it. Both close paths run border.configure with open: false.
  • Dockable.BorderContent is position: absolute against the layout's edge, and lets presses through to the tab panel (pointer-events: none, except on its splitter). A start or end overlay stops above and below the open top and bottom overlays.
  • Its stacking is yours: give the content a z-index so its splitter paints above the tabsets (the examples use data-overlay:z-30), and a shadow if you like.

Auto-hide borders

With autoHide: true (or defaults.border.autoHide), a border with no tabs is not rendered, so it takes no space. While a tab is dragged near that border's edge of the layout (within edgeDockMargin, away from the edge docking band in the middle of the edge), the border appears so the tab can be dropped into it; it hides again when the drag moves away or ends. Once it has a tab, it shows like any border. engine.is("border-shown", { borderId }) answers whether a border shows right now.

Edge indicators

A drop in the band along an edge of the layout docks the tab to that whole edge, as a new tabset. Dockable.EdgeIndicator marks those bands during a drag:

{(["top", "bottom", "start", "end"] as const).map((edge) => (
    <Dockable.EdgeIndicator key={edge} edge={edge} className="edge-indicator">
        <ArrowIcon edge={edge} aria-hidden />
    </Dockable.EdgeIndicator>
))}

Each indicator is positioned over its band (edgeDockMargin deep, edgeDockLength long, centred on the edge), shown while a drag that can dock to the edges is over the layout, and carries data-drop-target when the drop would dock there. It is pointer-events: none, and its stacking is yours. See Dockable.EdgeIndicator. Edge docking is on unless defaults.layout.edgeDock is false.

The band is 10px deep by default. A thin tab strip at the top of the layout sits inside it, so a drop aimed at the strip's middle docks to the top edge instead (gap 11 in Limitations). Lower defaults.layout.edgeDockMargin, or keep tab strips taller than twice the band.

Borders in the other examples

The IDE workbench keeps its explorer in the start border and the terminal and problems in a bottom border, and the drag and drop example shows the edge indicators.