INTERACTIVE DEMOS

@dannysir/floating-components

VS Code-style panel layouts in one React component

API Reference

ν•œκ΅­μ–΄ API λ¬Έμ„œ Β· ← Back to README

Complete reference for @dannysir/floating-components. For a quick overview and Quick Start, see the README.


Overview

The library is split into three concerns:

PieceFilePurpose
<TreeLayout />rendererRenders a tree of panels and splits as flexbox with resizable borders and HTML5 drag-and-drop
useLayoutTreehookManages tree state and returns mutating helpers bound to setTree
Tree utilitiespure functionsInspect or transform a tree outside the hook (e.g. with setTree directly)

<TreeLayout />

Recursively renders the layout tree using flexbox.

Props

PropTypeRequiredDefaultDescription
treeLayoutNodeYesβ€”Root node of the layout tree
componentsComponentStoreYesβ€”Registry mapping each panel's componentKey to the React node it renders. Create with createComponentStore
onResizeBorder(path: number[], borderIndex: number, delta: number, totalPixels?: number) => voidβ€”Border resize callback
onMovePanel(sourceId: string, anchorId: string, position: DropPosition, depth: number) => voidβ€”Drag-and-drop move callback
dragHandleSelectorstringβ€”CSS selector for drag handle. Omit to make the entire panel draggable
direction"vertical" | "horizontal" | "complex""complex"Restricts drag-drop to a single axis. In "vertical" mode the entire panel is divided into top/bottom drop zones by the Y midline (X is ignored); "horizontal" divides left/right by the X midline. "complex" keeps the default 4-edge classification (X-pattern). If the input tree contains splits whose direction conflicts with this prop, they are auto-normalized to match and a dev-mode console warning is emitted. Does not constrain useLayoutTree.splitPanel(...) direct calls
widthnumber | string"100%"Root container width. Defaults to filling the parent. Pass an explicit value to override
heightnumber | string"100%"Root container height. Defaults to filling the parent. Pass an explicit value to override
backgroundColorstringβ€”Background color of the root container
marginnumber | stringβ€”Margin of the root container
paddingnumber | stringβ€”Padding of the root container
resizerThicknessnumber | string8Hit area width/height. Visible bar is a 4px strip centered inside this area (2px transparent inset on each cross-axis side)
resizerLengthnumber | string"100%"Resizer cross-axis size (height when horizontal, width when vertical)
resizerColorstring"#0078d4"Color of the visible center bar. By default it fades in on hover via gradient mask
resizerHoverColorstring"#0078d4"Hover-state color (transitions 150ms). Falls back to resizerColor if unset
resizerHoverOnlybooleantrueFade the bar in only on hover. Set false to always show the bar

Sizing

TreeLayout fills its parent by default (width: 100%, height: 100%). The parent must have a defined size β€” if the parent collapses to its content height, the layout resolves to 0. Pass width/height props for explicit sizes, or wrap with a sized parent (e.g. 100vw/100vh).

Styling customization

Use the resizer* props and root container props to control the appearance.

Defaults already produce a modern hover-reveal line. Override only what you want to change:

<TreeLayout
  tree={tree}
  onResizeBorder={resizeBorder}
  backgroundColor="#1e1e1e"
  padding={4}
  resizerHoverColor="#ef4444"   // hover turns red instead of default blue
  resizerHoverOnly={false}       // always show the bar (still with gradient fade at ends)
/>
PropDefaultDescription
resizerThickness8Hit area width/height. Visible bar is 4px centered (2px inset each side)
resizerLength"100%"Cross-axis size β€” shorter values produce a centered handle look
resizerColor"#0078d4"Visible bar color (VS Code-style blue)
resizerHoverColor"#0078d4"Hover color. Transitions over 150ms. Defaults to same as resizerColor
resizerHoverOnlytrueBar is hidden at rest and fades in on hover (200ms). Set false to keep it visible
Border resize

ComponentStore

The tree stores only a string componentKey per panel, never a React element. The ComponentStore maps those keys to the actual React nodes and is passed to TreeLayout via the required components prop. This keeps the tree fully serializable (see Persistence).

createComponentStore(initial?)

Creates a store, optionally seeded from a Record<string, ReactNode>.

const store = createComponentStore({
  sidebar: <Sidebar />,
  editor: <Editor />,
});
MethodTypeDescription
register(key: string, node: ReactNode) => voidAdd or replace a mapping
unregister(key: string) => voidRemove a mapping
get(key: string) => ReactNode | undefinedLook up the node for a key
has(key: string) => booleanWhether a key is registered
  • Create the store once and keep a stable reference (module-level or useMemo).
  • register/unregister mutate the internal Map but do not trigger a re-render. To change what's rendered dynamically, swap the tree (setTree) instead of relying on store mutation.
  • When a panel's componentKey is not registered, the panel renders empty (null) and a dev-mode console warning is emitted.

Persistence

Because every value in the tree is primitive, the layout round-trips through JSON.stringify / JSON.parse with no custom serializer. The store lives in your code and is never serialized.

// save
localStorage.setItem("layout", JSON.stringify(tree));
 
// restore
const tree = JSON.parse(localStorage.getItem("layout")!) as LayoutNode;

Keep store keys stable across releases so restored trees resolve their components.


useLayoutTree(initialTree)

Hook for managing layout tree state. Returns the current tree plus selectors and mutating helpers.

const {
  tree, setTree,
  firstPanelId, panelIds, hasPanel,
  resizeBorder, splitPanel, removePanel, movePanel, insertPanel,
} = useLayoutTree(initialTree);

Return values

ReturnTypeDescription
treeLayoutNodeCurrent layout tree state
setTree(tree: LayoutNode) => voidDirectly set the tree
firstPanelIdstring | nullFirst panel id found in the tree (pre-order)
panelIdsstring[]All panel ids in the tree (pre-order)
hasPanel(panelId: string) => booleanWhether a panel exists in the tree
resizeBorder(path, borderIndex, delta, totalPixels?) => voidResize by border index within a split
splitPanel(panelId, direction, options?) => stringSplit a panel; returns new panel id
removePanel(panelId) => voidRemove a panel; auto-unwraps single-child splits
movePanel(sourceId, anchorId, position, depth?) => voidMove a panel
insertPanel(options: { panel: InsertPanelInit; at?: InsertAt }) => stringInsert a panel; returns new panel id

Method details

splitPanel(panelId, direction, options?)

Splits a panel by adding a sibling in the given direction. Returns the new panel's id.

splitPanel("editor", "vertical");
// Panel auto-generated id, empty componentKey ("")
 
splitPanel("editor", "horizontal", {
  newPanel: { id: "preview", componentKey: "preview" },
});
// => "preview"
  • If the parent split already matches direction, the new panel is inserted as a sibling
  • Otherwise the target panel is wrapped in a new split containing the old and new panels
  • newPanel.id β€” auto-generated via crypto.randomUUID() when omitted
  • newPanel.size β€” defaults to 0.5
  • newPanel.componentKey β€” defaults to "" (renders empty until a key is set)

removePanel(panelId)

Removes a panel from the tree. If removal leaves a split with a single child, the split is auto-unwrapped.

movePanel(sourceId, anchorId, position, depth?)

Moves an existing panel next to anchorId. Usually wired to <TreeLayout onMovePanel={movePanel} /> so the built-in drag-and-drop handler forwards position and depth.

  • position: "top" | "bottom" | "left" | "right" β€” drop side relative to the anchor panel
  • depth: 0 = panel level (sibling), 1 = parent split level, higher = ancestor split level

insertPanel({ panel, at? })

Inserts a new panel. Returns the new panel's id.

// Append to the root
insertPanel({ panel: { componentKey: "editor" } });
 
// Insert as sibling of an existing panel
insertPanel({
  panel: { id: "preview", componentKey: "preview" },
  at: { anchorId: "editor", position: "right" },
});
  • panel.componentKey is required (must be registered in the ComponentStore)
  • panel.id is auto-generated when omitted
  • panel.size defaults to 1
  • Omit at to append at the root level (see Tree utilities for root-append rules)

resizeBorder(path, borderIndex, delta, totalPixels?)

Resizes the border between two adjacent children of a split at path. Honors minSize/maxSize of the adjacent children. Usually wired to <TreeLayout onResizeBorder={resizeBorder} />.

Selector tips

  • firstPanelId β€” convenient for "focus the first panel" or "insert near the top" use cases
  • panelIds β€” panelIds.includes(id) acts as a visibility check for toggle UIs
  • hasPanel(id) β€” same check as panelIds.includes(id) but avoids creating a Set/array every render

Tree utilities

Pure functions for inspecting or transforming a tree without the hook. Useful when you already manage the tree state yourself (e.g. with Redux, Zustand, or raw setTree).

import {
  getFirstPanelId,
  getPanelIds,
  insertPanelIntoTree,
} from "@dannysir/floating-components";

getFirstPanelId(tree): string | null

Returns the id of the first panel encountered in pre-order traversal, or null if the tree has no panels (shouldn't happen in a well-formed tree).

getPanelIds(tree): string[]

Returns all panel ids in pre-order.

insertPanelIntoTree(tree, panel, at?): LayoutNode

Returns a new tree with panel inserted. panel must be a complete PanelNode.

// Append at root
insertPanelIntoTree(tree, panelNode);
 
// Insert as sibling of an anchor
insertPanelIntoTree(tree, panelNode, { anchorId: "editor", position: "right" });

Root append rules (when at is omitted):

  • If the tree root is a split, the panel is appended to the end of children (bottom for vertical, right for horizontal)
  • If the tree root is a panel, the root is wrapped in a horizontal split with the new panel on the right

If the anchor is not found in the tree, the original tree is returned unchanged (with a dev-mode warning).


Drag & Drop

The renderer registers HTML5 drag-and-drop listeners on each panel. When a user drags a panel, the drop target is computed from the hovered region, then onMovePanel(sourceId, anchorId, position, depth) is called.

Drag and drop

Drop target priority

The region around the hovered panel is partitioned:

  1. Root edge (outer ~5% of the root container) β€” drops at the top level. depth equals the number of ancestor splits.
  2. Parent split edge (outer ~15% of the enclosing split) β€” drops at the parent split level. depth = 1.
  3. Panel interior β€” drops at the hovered panel level. depth = 0.

Within each level, the cursor position decides position:

  • Left/right third β†’ "left" / "right" (results in a horizontal split)
  • Top/bottom third β†’ "top" / "bottom" (results in a vertical split)

depth parameter

depth shifts the insertion point upward through the anchor's ancestor chain:

  • 0 β€” insert as a sibling of the anchor panel itself
  • 1 β€” insert as a sibling of the anchor's parent split
  • n β€” insert as a sibling of the n-th ancestor split
  • >= ancestors.length β€” insert at the root level

This lets users drop near a panel to split it, or near the split edge to create a broader sibling.


Types

type SplitDirection = "horizontal" | "vertical";
 
interface PanelNode {
  type: "panel";
  id: string;
  size: number;            // flex ratio
  componentKey: string;    // key into the ComponentStore
  minSize?: number;        // pixel minimum (for resizeBorder clamping)
  maxSize?: number;        // pixel maximum
}
 
interface SplitNode {
  type: "split";
  direction: SplitDirection;
  size: number;            // flex ratio
  children: LayoutNode[];  // two or more children
  minSize?: number;
  maxSize?: number;
}
 
type LayoutNode = PanelNode | SplitNode;
 
type DropPosition = "top" | "bottom" | "left" | "right";
 
// insertPanel / insertPanelIntoTree helpers
interface InsertPanelInit {
  componentKey: string;  // required
  id?: string;           // auto-generated when omitted
  size?: number;
  minSize?: number;
  maxSize?: number;
}
 
interface ComponentStore {
  register: (key: string, node: ReactNode) => void;
  unregister: (key: string) => void;
  get: (key: string) => ReactNode | undefined;
  has: (key: string) => boolean;
}
 
interface InsertAt {
  anchorId: string;
  position: DropPosition;
}

Design notes

  • SplitNode has no id β€” paths (number[]) are used for identification. IDs would be redundant with structural paths and break when a split is auto-unwrapped.
  • A SplitNode with a single child is auto-unwrapped after removePanel / movePanel, keeping the tree flat.
  • size is a flex ratio, not pixels. Siblings share proportionally.