INTERACTIVE DEMOS

@dannysir/floating-components

VS Code-style panel layouts in one React component

@dannysir/floating-components

ํ•œ๊ตญ์–ด README ยท Live Demo ยท API Reference

Tree-based resizable and reorderable panel layout for React. Split panels horizontally or vertically, resize borders by dragging, and reorder panels via drag and drop โ€” just like VS Code or any modern IDE.

Layout overview

Features

  • N-ary tree structure โ€” SplitNode can hold two or more children, keeping the tree flat without unnecessary nesting
  • Border drag resize โ€” drag panel borders to resize (requestAnimationFrame optimized)
  • Drag-and-drop panel move โ€” reorder panels via HTML5 Drag & Drop API
  • Multi-level drop target โ€” distinguishes panel edge, parent split edge, and root edge for depth-aware placement
  • Immutable state โ€” all tree updates produce new objects via spread
  • View / State separation โ€” TreeLayout (rendering) and useLayoutTree (state) can be used independently
  • TypeScript โ€” full type declarations included
  • ESM + CJS โ€” dual-format bundle output

Installation

npm install @dannysir/floating-components

Peer dependencies: react >= 18


Quick Start

import {
  TreeLayout,
  useLayoutTree,
  createComponentStore,
  type LayoutNode,
} from "@dannysir/floating-components";
 
// 1. Map string keys to the React nodes they render.
const store = createComponentStore({
  "panel-a": <div style={{ padding: 16, background: "#dbeafe", height: "100%" }}>Panel A</div>,
  "panel-b": <div style={{ padding: 16, background: "#dcfce7", height: "100%" }}>Panel B</div>,
  "panel-c": <div style={{ padding: 16, background: "#ffedd5", height: "100%" }}>Panel C</div>,
});
 
// 2. The tree stores only string componentKeys โ€” no React elements.
const initialTree: LayoutNode = {
  type: "split",
  direction: "horizontal",
  size: 1,
  children: [
    { type: "panel", id: "panel-a", size: 1, componentKey: "panel-a" },
    {
      type: "split",
      direction: "vertical",
      size: 1,
      children: [
        { type: "panel", id: "panel-b", size: 1, componentKey: "panel-b" },
        { type: "panel", id: "panel-c", size: 1, componentKey: "panel-c" },
      ],
    },
  ],
};
 
const App = () => {
  const { tree, resizeBorder, movePanel } = useLayoutTree(initialTree);
 
  return (
    <div style={{ width: "100vw", height: "100vh" }}>
      <TreeLayout tree={tree} components={store} onResizeBorder={resizeBorder} onMovePanel={movePanel} />
    </div>
  );
};

TreeLayout fills its parent by default (width: 100%, height: 100%). Use a sized parent as above, or pass width/height props to set explicit dimensions.

The tree holds only primitive values (id, size, direction, componentKey), so JSON.stringify(tree) round-trips cleanly. See Persistence below.


Recipes

Toggle panel visibility

const { panelIds, removePanel, insertPanel } = useLayoutTree(initialTree);
 
const togglePanel = (id: string, componentKey: string) => {
  if (panelIds.includes(id)) {
    removePanel(id);
  } else {
    insertPanel({ panel: { id, componentKey } });
  }
};

Persistence

Because the tree contains only primitive values, you can save and restore the layout with plain JSON.stringify / JSON.parse โ€” no custom serializer needed. The ComponentStore (the key โ†’ React node mapping) lives separately in your code, so it never needs to be serialized.

const store = createComponentStore({
  sidebar: <Sidebar />,
  editor: <Editor />,
});
 
const STORAGE_KEY = "my-layout";
 
const load = (): LayoutNode => {
  const saved = localStorage.getItem(STORAGE_KEY);
  return saved ? (JSON.parse(saved) as LayoutNode) : defaultTree;
};
 
const App = () => {
  const { tree, resizeBorder, movePanel } = useLayoutTree(load());
 
  useEffect(() => {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(tree));
  }, [tree]);
 
  return <TreeLayout tree={tree} components={store} onResizeBorder={resizeBorder} onMovePanel={movePanel} />;
};

On restore, the tree's componentKeys are looked up in the store. If a key isn't registered, the panel renders empty and a dev-mode console warning is emitted โ€” so keep your store keys stable across releases.

Create the store once and keep a stable reference (module-level or useMemo). Calling register/unregister mutates the internal Map but does not trigger a re-render โ€” to change what's on screen dynamically, swap the tree (e.g. setTree) rather than relying on store mutation.


Drag & Drop

Drag any panel to reorder. A translucent preview of the drop target follows the cursor, and dropping near different regions produces different placements:

ezgif com-video-to-gif-converter
  • Drop on the panel center โ†’ split the hovered panel
  • Drop near the enclosing split's edge โ†’ place as a sibling of the parent split
  • Drop near the root's edge โ†’ place at the top level

Restrict to a single axis

By default TreeLayout uses 4-edge classification (direction="complex"). Pass direction to lock the layout to one axis:

<TreeLayout
  tree={tree}
  direction="vertical"
  onResizeBorder={resizeBorder}
  onMovePanel={movePanel}
/>
  • "vertical" โ€” drops classified by the Y midline (top/bottom only); only vertical splits are produced
  • "horizontal" โ€” drops classified by the X midline (left/right only); only horizontal splits are produced
  • "complex" (default) โ€” 4-edge classification with both axes

If the input tree contains splits whose direction conflicts with the prop, they are auto-normalized and a dev-mode console warning is emitted. useLayoutTree.splitPanel(...) direct calls are not constrained.

See API Reference โ†’ direction.

Wire it up with useLayoutTree's movePanel:

<TreeLayout tree={tree} onResizeBorder={resizeBorder} onMovePanel={movePanel} />

See API Reference โ†’ Drag & Drop for the full placement rules and the depth parameter.


Documentation


License

ISC