@n-uf/hypr-tiling
v26.11.6
Published
Dynamic tiling for React: a recursive split-tree renderer for drag/drop, resizable, keyboard-controlled panes — inspired by Hyprland.
Maintainers
Readme
[!CAUTION]
Not yet stable
hypr-tiling is young and still changing. Its API, theming and saved layout format are not final.
Version numbers are dates (YY.M.R), not semantic versions, so any release may break things, including the layout tree that
createPersistedTilingLayoutsaves tolocalStorage: after an upgrade, a saved tree may come back repaired or be replaced by your fallback layout. Pin an exact version and read the CHANGELOG before you upgrade. If you need a stable tiling layout library, use Dockview or react-mosaic.
Dynamic tiling for React: a recursive split-tree renderer for drag/drop, resizable, keyboard-controlled panes — inspired by Hyprland.
Reach for it when users need to rearrange dense, multi-panel screens at runtime — IDE-like tools, trading and operator consoles, analytics dashboards — while your app keeps strict, controlled ownership of the layout state.
Quick links
- Documentation homepage: https://hypr-tiling.n-uf.com/
- API report index:
etc/hypr-tiling.api.md - Repository issues: https://github.com/n-uf/hypr-tiling/issues
Install
pnpm add @n-uf/hypr-tiling react react-domnpm install @n-uf/hypr-tiling react react-domyarn add @n-uf/hypr-tiling react react-domreact and react-dom are peer dependencies (version ^19), optional: ./engine alone needs neither.
Versioning
The package uses calendar-aligned versioning, YY.M.R (year, month, release).
That scheme cannot signal a SemVer major bump. The library is in active
development, and backward compatibility may break between releases. Pin an
exact version in your manifest and read the per-release notes in
CHANGELOG.md before upgrading.
Entry points
| Import | Contents | React |
|---|---|---|
| @n-uf/hypr-tiling | TilingRenderer, theming, the curated public API | yes — "use client" module |
| @n-uf/hypr-tiling/devtools | observability panel and drag telemetry props | yes — "use client" module |
| @n-uf/hypr-tiling/engine | the pure layout / workspace-set / keymap engine (@beta) | no |
./engine is React-free and isomorphic: dist/engine.{mjs,cjs} is built as a
standalone bundle that shares no chunk with the two React entries and never
imports react / react-dom, so it is safe to import from server code and
react-server layers (Next.js route handlers, Server Components, Node scripts).
The invariant is guarded on every build by
__tests__/engine-entry-react-free.test.ts (pnpm test:dist: static scan of
the emitted bundle for createContext / from "react" markers plus a bare-Node
import with react made unresolvable) and by the engine ↛ react layering rule
in scripts/check-guardrails.mjs.
A host that places its own windows by a layout tree reads the renderer's geometry
from ./engine: resolveLayoutGeometry(layout, container, config) gives every
shown leaf's rect and every split's box with the ratio in effect, in CSS px and
unrounded, by the same pass the renderer lays panes out with
(resolveLeafRects gives the leaves alone).
Compatibility
- React:
^19 - React DOM:
^19 - Both are optional peers:
./enginealone needs neither, so an engine-only project installs without them - Tailwind scanning: required (
contentglob or v4@source) - Runtime: browser DOM (React renderer package)
Tailwind content requirement
The renderer styles its visual surfaces (pane shells, header, dividers,
focus frame, drop indicator) by emitting Tailwind utility class strings (via clsx +
tailwind-merge) — those classes only resolve to real styles if Tailwind scans
this package's built output and generates the matching CSS. If you do not
register the package in your Tailwind content glob, those surfaces render
unstyled.
Critical chrome ships as CSS, not purge-dependent classes. The functional chrome the tiler cannot work without — resize-divider
col-resize/row-resizecursors and the focus-outline handling below — is emitted by the renderer as a small stylesheet scoped to.hpt-root(React 19<style href>hoisting), keyed off data attributes rather than Tailwindcursor-*utilities. So resize cursors resolve correctly even if your Tailwind build never scans our dist. Tailwind scanning affects only the visual theme utilities, not these affordances.
Add @n-uf/hypr-tiling to your Tailwind content configuration:
// tailwind.config.js
export default {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"./node_modules/@n-uf/hypr-tiling/dist/**/*.{js,mjs}",
],
// ...
};For Tailwind v4 (CSS-first config), declare the same path as a source in your stylesheet:
@import "tailwindcss";
@source "../node_modules/@n-uf/hypr-tiling/dist/**/*.{js,mjs}";A custom overlayPortalContainer does not change that scan: library
dragChrome tokens still come from this package's dist. If the drag cursor
renders host-only Tailwind utilities (classes declared only in the
host app), those still need the host's own content / @source globs —
the portal mount does not pull them into the library scan.
Quick start
The renderer is a controlled component: you own the layout tree in state and
apply every change it reports through onLayoutChange. Nothing about the layout
is hidden inside the component — the tree is yours to persist, diff, and restore.
import {
TilingRenderer,
DEFAULT_TILING_LAYOUT_CONFIG,
type TilingLayoutNode,
type TilingTile,
} from "@n-uf/hypr-tiling";
import { useState } from "react";
const tiles: TilingTile[] = [
{ id: "a", title: "editor", content: <Editor /> },
{ id: "b", title: "preview", content: <Preview /> },
];
const initialLayout: TilingLayoutNode = {
kind: "split",
id: "root",
axis: "vertical",
ratio: 0.5,
first: { kind: "leaf", id: "l", tileId: "a" },
second: { kind: "leaf", id: "r", tileId: "b" },
};
export function Workspace(): JSX.Element {
const [layout, setLayout] = useState<TilingLayoutNode>(initialLayout);
return (
<TilingRenderer
layout={layout}
tiles={tiles}
config={DEFAULT_TILING_LAYOUT_CONFIG}
onLayoutChange={setLayout}
/>
);
}Programmatic layout API
Because you own the layout tree, you can mutate it from your own code — not just through drag/keyboard interaction. The package exports a set of pure layout reducers (each takes a layout node and returns a new one, never mutating in place) as supported public API. Use these to script layout changes, build custom commands, or restore persisted arrangements:
| Reducer | Purpose |
| --- | --- |
| findLeafById | Locate a leaf node within the tree by its id. |
| insertLeafAdjacent | Insert a new leaf next to an existing one along an axis. |
| moveLeafToRoot | Detach a leaf and re-seat it against the layout root. |
| moveLeafToSplitContainer | Move a leaf into a target split container. |
| swapLeafTiles | Exchange the tiles occupying two leaves. |
| removeLeafTile | Remove a leaf and collapse its parent split. |
| updateSplitRatio | Set the ratio of a binary split. |
| toggleSplitAxis | Flip a split between horizontal and vertical. |
| setLeafSizing | Set a leaf's sizing mode (static pixel extent vs. flexible). |
| groupLeaves | Collapse several leaves into one stacked/tabbed group. |
| ungroupNode | Expand a group back into individual leaves. |
| collectGroups | Enumerate the group nodes in a layout. |
| isStructurallyValidLayout | Validate a layout tree's structural invariants. |
All reducers are re-exported from the package root:
import {
findLeafById,
insertLeafAdjacent,
swapLeafTiles,
isStructurallyValidLayout,
} from "@n-uf/hypr-tiling";Other layout-tree helpers are exported for advanced/internal use, but the reducers above are the stable, documented surface for application code.
For complete generated API signatures, see
etc/hypr-tiling.api.md.
Persisted layout (optional glue)
createPersistedTilingLayout is a thin persistence adapter: it persists
ONLY the layout tree (splits, ratios, tileId leaves) to a key/value store
(localStorage by default) and delegates every heal to the engine's first-class
assertLayoutIntegrity / repairLayout. Repair is an engine trait, not a
persistence feature — the integrity APIs stay usable with or without this
helper. The storage key, expected tile ids, and fallback tree stay app-owned.
import { createPersistedTilingLayout } from "@n-uf/hypr-tiling";
// also available from "@n-uf/hypr-tiling/engine"
const persisted = createPersistedTilingLayout({
storageKey: "workspace.layout",
expectedTileIds: ["editor", "preview", "terminal"],
fallbackLayout: DEFAULT_LAYOUT,
config: DEFAULT_TILING_LAYOUT_CONFIG,
// storage? — defaults to window.localStorage (in-memory when no DOM)
// resolveContainerSize? — defaults to window.innerWidth/innerHeight
});
const [layout, setLayout] = useState<TilingLayoutNode>(() => persisted.load());
// onLayoutChange={(next) => setLayout(persisted.commit(next))}
// persisted.reset() restores + persists the fallback treeload() reads → structurally validates → checks integrity → returns a repaired
copy (or the fallback when nothing is stored / a saved tree fails hard
integrity). commit() validates → repairs → persists the healed tree.
Focus-outline handling (SDK-controlled)
By default the renderer suppresses native UA focus outlines on its own chrome
(pane roots, tabs, buttons) via a stylesheet scoped to .hpt-root — it never
touches app chrome. Opt out with the chromeFocusOutline prop to keep native
browser focus rings:
<TilingRenderer chromeFocusOutline="native" /* … */ />
// "suppress" (default) | "native"Drag-state chrome (theme.dragChrome)
Every drag surface the renderer paints — the lifted pane's shadow, the drop
indicator, the drop-target highlight, the pointer-pinned drop-validity badge —
reads the theme's optional, partial dragChrome slot. The dragged pane is
the real pane (no ghost copy), so it keeps its own at-rest look; omitted tokens
fall back to the defaults (resolveDragChrome(theme)). To make a square, flat
host drag square and flat:
const THEME: TilingTheme = {
...TILING_THEME_REGISTRY["clean-flat"],
paneShell: { ...base.paneShell, surface: "… rounded-none border border-slate-700 bg-slate-900" },
dragChrome: {
liftedPane: "drop-shadow-[0_8px_20px_rgba(0,0,0,0.5)]", // follows the pane as it is drawn
dropIndicator: "rounded-none border border-dashed border-amber-500 bg-amber-500/15",
cursorBadge: "rounded-none border",
},
};Theme scoping. dragChrome tokens are literal class strings so
Tailwind's JIT can emit them — that is not a portal constraint. The lifted
pane and the drop indicator render inside the renderer, so they inherit the
host's scoped CSS. Only the custom drag cursor portals; host CSS that scopes
colours via CSS variables, data-theme, or a scoped dark class reaches it
only if it mounts on the overlay portal container (default document.body,
see overlayPortalContainer):
const overlayRef = useRef<HTMLDivElement | null>(null);
return (
<div data-theme="dark" ref={overlayRef}>
<TilingRenderer overlayPortalContainer={() => overlayRef.current} /* … */ />
</div>
);The container must not sit under a transform / filter /
backdrop-filter / perspective / contain: paint ancestor. Overlay
z-indexes (220 / 230) are relative to that container's stacking context.
Stable pane identity (paneIdentity)
By default on a client-only mount, a pane's React instance survives every layout
edit — drag → drop → settle, swaps, group folds — without remounting: panes
render once in a hidden tile-keyed pool and their DOM node is relocated into
whichever slot shows their tile. Hooks, state, refs, iframes, and scroll
positions are preserved. When the renderer hydrates server markup it stays in
the legacy in-place mode ("slot"); pass paneIdentity="stable" to opt an SSR
host in (server HTML then carries empty pane slots). A drag lifts the real
pane: in stable mode the same instance is held under the pointer and dropped;
in slot mode the lifted pane is mounted afresh at the lift and again at the
drop (state inside the tile does not carry over).
<TilingRenderer paneIdentity="stable" /* "auto" (default) | "stable" | "slot" */ />Overlay portal container (theme scoping)
The custom drag cursor portals to document.body by default so its
position: fixed coordinates stay window-relative — immune to ancestor
transform / filter / backdrop-filter / contain: paint containing
blocks. (The lifted pane does not portal: it is a fixed layer inside the
renderer whose containing block is measured, so it escapes the viewport's
clip and follows scroll and scale.) A host whose theme tokens live on a
scoped root (CSS variables that the cursor must inherit) can redirect the
portal:
<TilingRenderer
overlayPortalContainer={overlayHost} // HTMLElement
// overlayPortalContainer={() => overlayRef.current} // thunk, re-read each render
/* … */
/>The thunk form is evaluated every render so a late-mounted container is
picked up without remounting the renderer. null / omitted falls back to
document.body.
Containing-block caveat. The container must not sit under an ancestor
with transform, filter, backdrop-filter, perspective,
contain: paint (or layout / strict / content), or will-change of
those. The cursor uses window-relative client coordinates; a
containing-block ancestor offsets it from the pointer.
External drop targets
When a tile is dragged over host chrome outside the tiling tree (a
workspace tab strip, a chat panel), the host reports that hover; the held
pane stays visible over it (it is not clipped by the viewport). On release
the host claims the leaf before the drag FSM settles: the pane leaves
the tree and fades out where it was drawn. This is the standalone subset of
the WorkspaceSet drag scope — see
_agent/workspace-set-concept.md
§5.5.
<TilingRenderer
externalDragHover={
tabOver ? { targetId: tabOver.id, point: tabOver.point } : null
}
onExternalDrop={(leafId, targetId, point, hover, subject) => {
// synchronous claim; a dragged tab group is handed over whole
for (const member of subject.members) {
moveItemToTab(member.id, targetId, point);
}
if (subject.kind === "group") {
const groupId = subject.groupId;
setLayout((tree) => removeGroupNode(tree, groupId));
}
}}
/* … */
/>removeGroupNode is imported from @n-uf/hypr-tiling/engine. leafId is the
dragged pane (a dragged group's active tab). The fifth argument, subject,
says what the claim hands over: one pane (kind: "pane"), or, when the drag
moved a tab group, the whole group (kind: "group", which narrows groupId
to a string) with every member in tab order in subject.members. The host
takes every member and takes the group out of its layout; one that takes only
some would see the rest flash out and back while the claim settles. A host
that ignores subject still gets the active pane's leaf id first, as before.
A workspace tab in set mode takes the whole group on its own
(moveGroupToWorkspace).
Omit onExternalDrop (or return false from it) and the release cancels:
the layout stays as it was at the lift and the pane settles home.
Workspaces
Several layout trees over one tile pool, one active at a time — the
window-manager "workspace" (i3 / Hyprland / Sway). The engine owns the set
(TilingWorkspaceSet, pure ops, integrity walker + repair); the renderer
paints the active tree and switches without remounting a pane whose
leaf exists in both workspaces; the headless tab strip owns the WAI-ARIA
semantics, keyboard model and drop-target wiring. Design record:
_agent/workspace-set-concept.md.
import {
TilingRenderer,
useTilingWorkspaceTabs,
workspaceSetOfLayout,
createWorkspace,
type TilingWorkspaceSet,
} from "@n-uf/hypr-tiling";
function Dashboard() {
const [set, setSet] = useState<TilingWorkspaceSet>(() =>
workspaceSetOfLayout(initialLayout), // one "Main" workspace
);
const tabs = useTilingWorkspaceTabs({
workspaces: set,
onWorkspacesChange: setSet,
onRenameRequest: (ws) => openRenameDialog(ws), // commit via tab.rename(name)
onCloseRequest: (ws) => confirmClose(ws), // commit via tab.close()
});
return (
<>
<div {...tabs.tablistProps}>
{tabs.tabs.map((tab) => (
<button key={tab.workspace.id} {...tab.tabProps}>
{tab.workspace.name}
</button>
))}
<button onClick={() => tabs.create({ id: crypto.randomUUID(), name: "New" })}>+</button>
</div>
<div {...tabs.panelProps(set.activeId)}>
<TilingRenderer
workspaces={set}
onWorkspacesChange={setSet}
tiles={tiles}
config={DEFAULT_TILING_LAYOUT_CONFIG}
{...tabs.rendererProps} // tabs become native drop targets
/>
</div>
</>
);
}- Engine ops (pure,
set → set):createWorkspace,renameWorkspace,deleteWorkspace(refuses the last; returnsremovedTileIdsshown only there),switchWorkspace,cycleWorkspace,setWorkspaceLayout,moveLeafToWorkspace(set, leafId, to, placement?),showInWorkspace,hideFromWorkspace, tile-keyedmoveTileToWorkspace,showTileInWorkspace,hideTileFromWorkspace,removeTile,revealTile,resetWorkspaceLayout,resetWorkspaceSet,workspaceSetEquals,queryWorkspaceSet,workspaceSetIssues,repairWorkspaceSet.TilingWorkspacePlacementadds{ kind: "region"; region: "start" | "end" }for reading-order ends. Limits:TILING_WORKSPACES_MAX(12),TILING_WORKSPACE_NAME_MAX_CHARS(40). A leaf may sit in several trees (showInWorkspace); there is no pin table (TilingLeafPindid not ship). - Renderer —
workspaces+onWorkspacesChangereplacelayout+onLayoutChange. Uncontrolled focus / maximize are remembered per workspace; a drag in flight is cancelled when the active workspace changes; an empty workspace rendersrenderEmptyWorkspace(workspace). tilesis the whole pool — pass every tile any workspace seats. Coverage is checked per tree: the active layout is healed against the tiles it seats (no empty leaves, no duplicate seats, no seat for a tile missing from the pool); a pool tile shown only elsewhere is never forced into the active tree. Set-wide integrity is the wrapper's job:orphanTiles?: "seat-in-active" | "ignore" | "report"(default"report") — a pool tile no workspace seats / a seat whose tile is not in the pool."report"leaves the set alone and callsonIntegrityIssues(issues)whenever the issue fingerprint changes (once more with[]when it clears);"seat-in-active"runsrepairWorkspaceSetonce per set identity (orphans seated in the active tree withTILING_DEFAULT_WORKSPACE_PLACEMENT, unknown seats pruned), emits the repaired set ononWorkspacesChange, and reports what it repaired;"ignore"does neither.mintLeafId?: (tileId) => stringnames a minted seat (default`leaf:${tileId}`).inactiveWorkspaces?: "unmount" | "keep-mounted"(default"unmount") — withpaneIdentity: "stable","keep-mounted"parks a pane whose tile is seated only in an inactive workspace in the hidden pool (display: none) instead of unmounting it, so an iframe / video / editor survives a round trip. Cost: every retained pane's DOM and React subtree stays alive (memory, hidden-subtree effects). A pane is retained only after its first paint; an empty active workspace still unmounts everything; no-op underpaneIdentity: "slot".renderTilereceivesworkspaceId(the workspace the pane is painted in) andseatCount(how many workspaces seat the tile) — single-layout mode reportsTILING_MAIN_WORKSPACE_ID/1.
- Drop on a tab — a
kind: "workspace-tab"externalDragHover(whattabs.rendererPropsresolves for you) settles bymoveLeafToWorkspacewith no host claim;onMoveLeaf(leafId, from, to)reports it. Withinteraction.workspaces.followMovedLeaf: truethe sameonWorkspacesChangeis already switched to the destination (onWorkspaceSwitch({ via: "tab-drop" })) — do not switch again fromonMoveLeaf. UnderpaneIdentity: "stable"the dragged pane stays parked in the pool for the whole drag (over empty space, over a tab, into another workspace), so the release reseats the same mount and in-pane local state survives. - Tab strip —
role="tablist"/role="tab", rovingtabIndex, Arrow keys / Home / End move focus, Enter / Space activate, F2 →onRenameRequest, Delete →onCloseRequest. Unstyled until a theme suppliesworkspaceTabs/workspaceTab/workspaceTabActive/workspaceTabDropTarget.
Workspace set controller
A persisted document (value / onCommit) plus in-flight tree overlays.
Tree edits coalesce for treeDebounceMs (default 400) and commit as
"tree"; create / rename / remove / switch commit immediately as
"lifecycle"; moveTile as "move"; reveal as "reveal" when
changed !== "none". readOnly keeps switches local and commits nothing.
maxWorkspaces caps create() (default TILING_WORKSPACES_MAX).
ctl.flush() commits pending trees now; ctl.pending is true while a
tree debounce is open. Pass ctl.set and ctl.onWorkspacesChange to both
the renderer and the tab strip.
import {
TilingRenderer,
useTilingWorkspaceSetController,
useTilingWorkspaceTabs,
} from "@n-uf/hypr-tiling";
function Dashboard({ persisted, persist, readOnly, tiles }) {
const ctl = useTilingWorkspaceSetController({
value: persisted,
onCommit: (next, reason) => persist(next, reason),
readOnly,
mintWorkspaceId: () => crypto.randomUUID(),
nextWorkspaceName: (existing) => `Workspace ${existing.length + 1}`,
});
const tabs = useTilingWorkspaceTabs({
workspaces: ctl.set,
onWorkspacesChange: ctl.onWorkspacesChange,
onRenameRequest: (ws) => ctl.rename(ws.id, window.prompt("Name", ws.name) ?? ws.name),
onCloseRequest: (ws) => ctl.remove(ws.id),
});
return (
<>
<div {...tabs.tablistProps}>
{tabs.tabs.map((tab) => (
<button key={tab.workspace.id} {...tab.tabProps}>
{tab.workspace.name}
</button>
))}
<button onClick={() => ctl.create()}>+</button>
</div>
<TilingRenderer
workspaces={ctl.set}
onWorkspacesChange={ctl.onWorkspacesChange}
tiles={tiles}
{...tabs.rendererProps}
/>
</>
);
}Reset to defaults
Two modes, both keep the visitor on the workspace they are looking at
(activeId never changes). Pass the seed as workspaceDefaults on the
renderer (without it, the reset commands are no-ops) and as defaults on
the controller.
resetWorkspaceLayout(set, defaults, workspaceId?)/{ kind: "reset-workspace"; workspaceId? }— replace one workspace'slayoutandnamefrom the seed workspace of the same id (workspaceIdomitted →set.activeId). If the seed has no such id, that workspace'slayoutbecomesnulland its name is kept.resetWorkspaceSet(set, defaults)/{ kind: "reset-workspaces" }— replace the whole set from the seed, keepingset.activeIdwhen that id exists in the seed.- Controller:
ctl.reset("workspace" | "all", workspaceId?)commits immediately (same path asswitch, not tree-debounced) and returnsfalsewhen nothing changed ordefaultsis omitted.ctl.atDefaultsistruewhenreset("all")would change nothing — every workspace matches the seed, whichever one is active (falsewith no defaults). No default key bindings — host opt-in only.
const ctl = useTilingWorkspaceSetController({
value: persisted,
defaults: SEED,
onCommit: persist,
mintWorkspaceId: () => crypto.randomUUID(),
nextWorkspaceName: (existing) => `Workspace ${existing.length + 1}`,
});
<TilingRenderer
workspaces={ctl.set}
workspaceDefaults={SEED}
onWorkspacesChange={ctl.onWorkspacesChange}
tiles={tiles}
/>;Workspace commands & keymap
Workspace navigation is typed TilingCommand surface — same handle as layout
commands. Opt in to default chords with WORKSPACE_KEY_BINDINGS spread into
interaction.keyBindings.bindings (Alt+Arrow cycle, Alt+1..9 switch by
index, Alt+Shift+Arrow move focused leaf + follow). Gate dead controls with
TilingCommandGates.workspacesEnabled (false in single-layout mode).
import { TilingRenderer, WORKSPACE_KEY_BINDINGS } from "@n-uf/hypr-tiling";
<TilingRenderer
workspaces={set}
onWorkspacesChange={setSet}
onWorkspaceSwitch={({ from, to, via }) => …}
interaction={{
keyBindings: { bindings: [...WORKSPACE_KEY_BINDINGS] },
workspaces: { followMovedLeaf: true },
}}
commandRef={commandRef}
/* … */
/>;Dispatch from app code: commandRef.current?.dispatch({ kind: "switch-workspace", workspaceId }),
{ kind: "cycle-workspace", direction: "next" }, { kind: "move-leaf-to-workspace", … },
{ kind: "reveal-tile", tileId }, { kind: "reset-workspace" }, { kind: "reset-workspaces" }.
reveal-tile prefers the active workspace when it already shows the tile.
Reset commands require workspaceDefaults and keep activeId.
onWorkspaceSwitch fires beside onWorkspacesChange when activeId changes
through the renderer. TilingWorkspaceSwitchVia: "key" (keymap),
"command" (imperative dispatch, including a host tab strip that calls
switch-workspace), "swipe", "spring-load", "reveal", "tab-drop"
(plain tab release with followMovedLeaf: true). "tab" is reserved (not
emitted; a strip that dispatches switch-workspace is "command").
Workspace switch transition
Opt in with interaction.workspaces.switch.transition: "slide" | "fade"
(default "none"). The set-mode wrapper then mounts
<WorkspaceTransitionStage> inside its viewport around the live tree and
drives it: the outgoing viewport is frozen as a DOM clone
(captureViewClone) before the tree swaps — for renderer-driven
switches (command, key, tab, reveal, swipe) and for a host-driven
activeId change alike — and slid or faded against the live incoming
tree, then dropped. A tracking swipe scrubs the stage from its progress;
releasing short of the commit threshold settles it back; a committed swipe
keeps its capture. cycle-workspace keeps its own direction across a
wrap-around; other switches derive prev / next from tab order.
<TilingRenderer
workspaces={set}
onWorkspacesChange={setSet}
interaction={{
workspaces: { switch: { transition: "slide", wheelSwipe: true } },
}}
/* … */
/>prefers-reduced-motion: reduce resolves to "none" (instant, no clone).
A tainted or oversized <canvas> capture falls "slide" → "fade". Theme
token workspaceTransition (durationMs, easing) supplies the timed
curve (default 200 ms, cubic-bezier(0.2, 0.8, 0.2, 1)). Because the clone is a snapshot,
inactiveWorkspaces: "keep-mounted" is not needed for the animation — it
only matters for panes whose live state must survive the switch.
useWorkspaceTransition / WorkspaceTransitionStage stay exported for a
host that animates its own viewport (call begin before updating
activeId, scrub(progress) while tracking, finish("commit" | "cancel")
to settle; the viewport needs position: relative; overflow: hidden).
Swipe between workspaces
Opt in per input with interaction.workspaces.switch (both off by default):
a horizontal trackpad / wheel burst or a one-finger touch swipe on the
renderer cycles to the neighbouring workspace through the same command path
as the keymap (onWorkspacesChange + onWorkspaceSwitch({ via: "swipe" })).
The gesture never arms while a pane drag is in flight, while Ctrl is held
(pinch-zoom), for a mostly vertical delta, or while an inner element under
the pointer can still scroll horizontally; the first / last workspace has no
neighbour on that side unless wrap: true. A wheel run is claimed at its
sequence start: if the first sample(s) fail arming, later samples of
that run (gaps < wheelIdleMs) cannot arm, even if they become horizontal —
so a vertical scroll's horizontal tail, and trackpad momentum after a
commit, stay with the page. While armed, the |dx| > 2·|dy| ratio must
hold cumulatively across the window (a gradually diagonal path drops
back to scroll); once tracking, vertical drift is tolerated. Optional
modifier: "meta" | "alt" | "shift" requires that key on the arming
samples only (release mid-track does not cancel); touch ignores it. When
enabled the root gets overscroll-behavior-x: contain (and
touch-action: pan-y for touch), including when a modifier is set —
wheel without the modifier still scrolls normally (preventDefault only
while tracking).
<TilingWorkspaceSwipeScope>
<TabStrip /> {/* const { progress, target, phase } = useWorkspaceSwipe(); */}
<TilingRenderer
workspaces={set}
onWorkspacesChange={setSet}
interaction={{
workspaces: {
switch: { wheelSwipe: { modifier: "meta" }, touchSwipe: true },
},
}}
/* … */
/>
</TilingWorkspaceSwipeScope>wheelSwipe takes true or a partial TilingWorkspaceSwipeConfig
(thresholdPx 24, commitFraction 0.35, commitVelocityPxMs 0.6,
wheelIdleMs 120, lockoutMs 350, wrap false, modifier null). useWorkspaceSwipe() is
headless — progress is -1..1 (negative = towards the previous workspace)
so a tab indicator can follow the finger; it reads the idle snapshot outside
a TilingWorkspaceSwipeScope. With a switch transition on, phase stays
"settling" until the stage has finished sliding (commit or cancel); with
"none" the settle is immediate. The pure FSM (workspaceSwipeReducer) and
the port contracts ship on @n-uf/hypr-tiling/engine.
Spring-loaded tab drop
Opt in with interaction.workspaces.springLoad (off by default; true-like
values are {} or a partial { dwellMs }, default 500 ms). It needs the
host to report the workspace tab under a pane drag as a
kind: "workspace-tab" external hover — through resolveExternalDragHover
(point → hover) or the externalDragHover prop — the same hover the
release-on-tab drop already uses.
<TilingRenderer
workspaces={set}
onWorkspacesChange={setSet}
onMoveLeaf={(leafId, from, to) => …}
onWorkspaceSwitch={(event) => … /* event.via === "spring-load" */}
resolveExternalDragHover={(point) => tabStrip.hoverAt(point)}
interaction={{ workspaces: { springLoad: { dwellMs: 400 } } }}
/* … */
/>Holding a dragged pane over another workspace's tab for dwellMs moves
the leaf into that workspace (moveLeafToWorkspace, default placement),
switches to it (one onWorkspacesChange; a switch.transition animates it)
and fires onMoveLeaf + onWorkspaceSwitch({ via: "spring-load" }) — then
the drag continues on the leaf's new seat in the destination tree under
the still-held pointer, so the user can place it, spring-load on to a third
workspace, or press Escape to settle it back into that seat. The active
workspace's own tab never dwells; leaving the tab before the deadline resets
the dwell; releasing or cancelling mid-dwell runs the ordinary tab drop /
cancel. The dwell FSM (springLoadReducer) and the drag REARM edge ship on
@n-uf/hypr-tiling/engine.
Migration from a per-workspace layouts map
Hosts that kept Record<workspaceId, TilingLayoutNode> plus activeId (and
remounted the renderer with key={activeId}) replace that map with one
TilingWorkspaceSet:
const set: TilingWorkspaceSet = {
workspaces: Object.entries(layouts).map(([id, layout]) => ({
id,
name: names[id] ?? id,
layout,
})),
activeId,
};Pass workspaces + onWorkspacesChange (or useTilingWorkspaceSetController)
and drop the remount key. tiles is the whole pool — do not slice to the
active tree. Tab drop with followMovedLeaf: true already switches in the
same onWorkspacesChange (via: "tab-drop") — do not call switchWorkspace
from onMoveLeaf.
Tab groups
grouping.enable (default on) lets a drag merge panes into a stacked group.
The built-in strip is the canonical chrome: with grouping.showGroupTabStrip
left at its default true, a group of two or more members paints a tab row
(role="tablist") above the pane header. Tabs are content-width and
left-aligned inside a rounded rail — they never stretch to fill the row
(they shrink only when the rail overflows, min-width 56px, ellipsis, full
title in the title attribute). The active pill uses the quiet group tokens
by default; inactive tabs use tabColor at full opacity (hover uses
tabHoverColor). Arrow keys, Home, and End move the active member (the
same group-tab-jump path as a click). The right end ejects the active member
(remove-from-group, a sibling split) and ungroups. groupTabStrip.showEject
and groupTabStrip.showUngroup both default to true; set either to false
to hide that control.
When a group member is maximized and the pane strip is shown, the group row
is the nested tier: 4px shorter than the height token, indented ~18px,
data-nested="true" / --hpt-tab-strip-nested: true, a └ tick in
controlColor before the rail, and the nestedTabActive* tokens on the
active pill. A standalone group strip is not nested.
placement: "bottom" draws that same row under the pane body. Either
placement subtracts the rendered strip height from the member content box so
the body does not clip. The strip sits in the group's own stacking context
(same layer as the pane header, not an overlay). The pill transition is
none under prefers-reduced-motion. accent is the keyboard
:focus-visible outline colour.
| groupTabStrip field | Default | What it does |
| --- | --- | --- |
| placement | "top" | "top" above the header, "bottom" under the body |
| height | 28 | Strip height in CSS pixels; subtracted from the content box |
| showUngroup | true | Ungroup control at the right end |
| showEject | true | Eject the active member at the right end |
| theme | dark row, quiet pill | CSS-value tokens (see below); omitted tokens keep the group default |
| renderTabLabel | tile title | (member) => ReactNode; member is { id, tileId, title, active } |
Theme tokens (TilingGroupTabStripTheme, all optional CSS values):
| Token | Group default | Pane default | What it does |
| --- | --- | --- | --- |
| background | rgba(0, 0, 0, 0.45) | same | Strip row background |
| borderColor | rgba(255, 255, 255, 0.1) | same | Strip hairline bottom border |
| tabColor | rgb(148, 163, 184) | same | Inactive pill label |
| tabHoverColor | rgb(226, 232, 240) | same | Inactive pill label on hover |
| tabActiveColor | rgb(255, 251, 235) | rgb(12, 13, 16) | Active pill label (standalone) |
| tabBackground | transparent | same | Inactive pill background |
| tabActiveBackground | rgba(255, 255, 255, 0.08) | rgb(252, 211, 77) | Active pill background (standalone) |
| accent | rgb(252, 211, 77) | same | :focus-visible outline |
| fontFamily | ui-monospace, SFMono-Regular, Menlo, monospace | same | Pill label font |
| fontSize | 10px | same | Pill label size |
| letterSpacing | 0.08em | same | Pill label tracking |
| radius | 0px | same | Pill corner radius; rail is radius + 2 |
| gap | 2px | same | Gap between pills in the rail |
| paddingX | 6px | same | Strip row horizontal padding |
| controlColor | rgb(168, 162, 158) | same | Eject / ungroup / nest-tick |
| controlHoverColor | rgb(254, 243, 199) | same | Controls on hover |
| railBackground | rgba(255, 255, 255, 0.02) | same | Rounded rail fill |
| railBorderColor | borderColor | same | Rail 1px border; omitted follows borderColor |
| nestedTabActiveBackground | rgba(255, 255, 255, 0.08) | same | Active pill fill on the nested group tier |
| nestedTabActiveColor | tabActiveColor | same | Active pill label on the nested group tier |
They resolve like dragChrome (partial over the strip's default set —
TILING_GROUP_TAB_STRIP_THEME_DEFAULTS or
TILING_PANE_TAB_STRIP_THEME_DEFAULTS) and apply as inline styles plus
--hpt-group-tab-* custom properties on the strip (hpt-tab-strip; group
also hpt-group-tab-strip, pane also hpt-pane-tab-strip). The rail is
.hpt-tab-strip-rail.
A drop on a strip tab resolves group-merge and inserts the dragged leaf at
that tab's index (the hovered member shifts right). A drop on the strip past
the last tab appends. Host targets do not insert by index.
interaction={{
grouping: {
showGroupTabStrip: true,
groupTabStrip: {
placement: "top",
theme: { background: "#0c0d10", accent: "rgb(252, 211, 77)" },
},
},
}}Hosts that still set grouping.showGroupTabStrip: false can paint their own
chips. Attach args.groupDropTargetRef to the element that should mean
"drop here to group with this pane" — a title bar, a chip row, or both (the
same callback may be attached to more than one element). That hit is the
secondary path: it resolves the same group-merge commit, but it always
appends.
A pointer over that element during a drag resolves group-merge. The target
is a loose leaf → a new group {target, source} with the source active. The
target is already a group → the source is appended and made active. The drag
source's own target is skipped, and so is a target whose group already
contains the source; those hits fall through to the pane body. The whole hit
is off when grouping.enable is false. It works with the built-in strip
shown or hidden.
While the pointer is over a group-merge target — the built-in strip, or a
host element (fallbackReason: "host-group-drop-target") — the target
pane's preview is { mode: "group-merge", role: "drop-target-result-shadow",
… }. Other panes, including the drag source, keep preview: null for that
intent. Hosts paint a merge ring from preview.mode === "group-merge". Live
drag mode still suppresses the per-tile preview.
Precedence, highest first: the built-in strip, then the host element (it wins
over the centre swap and over any edge band the element covers), then
uncovered edge bands (edge-insert), then the pane centre (swap).
<header ref={args.groupDropTargetRef} onPointerDown={args.onHandlePointerDown}>
{args.tile.title}
</header>Multi-select grouping
With paneSwitching.multiSelectGrouping (default on) and grouping.enable,
Alt/Opt+click on a pane header toggles panes into a transient multi-selection
(isMultiSelectGroupingEnabled, isMultiSelected, onToggleMultiSelect).
multiSelectionCount is the number of leaves currently multi-selected in the
active workspace (0 when none). Every pane of that workspace reports the
same count, including panes that are not themselves selected, so a host can
show Cancel when the count is 1. When at least two are selected and grouping
would change the layout, canGroupMultiSelection is true and
onGroupMultiSelection(clickedLeafId) folds them into one tabbed group at the
clicked pane's slot. Escape clears the selection; hosts can call
onClearMultiSelection() from their own Cancel control to run the same clear
path without walking every pane's toggle. Inactive retained panes report
multiSelectionCount: 0.
Maximize
Maximize is a non-destructive render mode: the focused pane fills the tiling
viewport and siblings are hidden; the layout tree is not rewritten. Restore
with a second maximize, Escape, or the header control.
maximize.keepGroupTabStrip (default true) keeps the group's tab strip
when the maximized leaf is a group member. The renderer paints the GROUP
node at viewport size and overrides activeMemberId to the maximized leaf
at render time (no onLayoutChange). The strip height is subtracted from
the maximized member. When the pane strip is also shown, that group row is
the nested tier (see Tab groups). grouping.showGroupTabStrip: false still
hides the strip. Set keepGroupTabStrip: false to fill the viewport with
the leaf alone (the previous grouped-maximize look).
interaction={{
maximize: { enable: true, keepGroupTabStrip: true },
}}Pane switching
paneSwitching.showTabStrip governs the top-level pane strip (every outer
pane, a group as one tab). true (default) always paints it; false hides
it; "maximized" paints it only while a pane is maximized. Cycle / jump
shortcuts work regardless. Clicking a tab calls activateLeaf, which
switches the maximized pane while maximized.
paneSwitching.tabStrip themes that strip with the same tokens as
grouping.groupTabStrip (TilingGroupTabStripTheme, --hpt-group-tab-*),
resolved over TILING_PANE_TAB_STRIP_THEME_DEFAULTS (solid active pill).
The strip root is hpt-tab-strip hpt-pane-tab-strip. Tabs are content-width
in a rail; they never stretch.
| tabStrip field | Default | What it does |
| --- | --- | --- |
| placement | "top" | "top" above the viewport, "bottom" after it |
| height | group-strip height (28) | Strip height in CSS pixels |
| theme | pane-strip solid pill | Same CSS-value tokens as groupTabStrip.theme; omitted tokens keep the pane defaults |
| renderTabLabel | tile title | (tab) => ReactNode; tab is { leafId, tileId, title, active, maximized, ordinal, groupId, memberCount } |
The lab chrome (wordmark, theme picker, accent picker, content toggle)
renders only when showTabStrip === true and those lab hooks are passed
(onThemeChange / onTileAccentChange / showContentToggle).
"maximized" is the lean strip only.
interaction={{
paneSwitching: {
showTabStrip: "maximized",
tabStrip: {
placement: "top",
theme: { background: "#0c0d10", accent: "rgb(252, 211, 77)" },
},
},
}}Features
- Drag/drop rearrange — Hyprland-style live drag; the move commits on
release, resolving to swap, edge-insert, split-container-insert, or
group-merge (built-in strip, or a host
groupDropTargetRefwhen the strip is hidden). - Resizable split dividers — drag dividers, or pin a pane to a measured pixel extent (static) versus ratio-distributed (flexible).
- Group / stack tabs — collapse several leaves into one slot as a stacked group with a tab strip; only the active member renders.
- Maximize — promote any pane to fill the viewport and restore it back.
- Keyboard-driven focus — directional focus, a pane switcher (cycle / jump / overlay), keyboard move-mode, and master/group commands behind a remappable keymap.
- Theming engine — three built-in themes (
neon-terminal,clean-flat,mosaic), eight accent hues, a themeable drag state (dragChrome),overlayPortalContainerfor a theme-scoped drag cursor, and live theme switching with no remount. - Titlebar-only pane collapse — opt-in
paneTitleBarControls.collapsepins a pane to its chrome extent;resizeFloordefaults to"chrome";onPaneCollapsedChange/minBBoxPxtravel with the leaf. - Workspaces — several layout trees over one tile pool
(
TilingWorkspaceSet): set ops,useTilingWorkspaceSetController,useTilingWorkspaceTabs+ native tab drop,WORKSPACE_KEY_BINDINGS, swipe / slide-fade transition / spring-load. See Workspaces above. - Held drag — the real pane is lifted and held under the pointer; a drop
indicator at the exact landing rect or snap-to-slot (
dragPreview,dragPreviewMakesRoom). - External drop — host-reported
externalDragHover, andonExternalDropclaim-before-settle. In set mode akind: "workspace-tab"hover is an engine-owned drop target. - Stable pane identity — host pane content survives drag → drop → settle
and every other tree edit without remounting (
paneIdentity). - Persisted layout adapter —
createPersistedTilingLayoutsaves the tree (localStorage by default) and heals it throughassertLayoutIntegrity/repairLayout. - Self-healing drag recovery — a frame-deadline backstop, an idle watchdog,
transient-style teardown, and a
visibilitychangereconcile so a drag never strands the tree mid-transition.
Use cases
Any screen with several panes the user rearranges:
- Dashboards — metrics panes that share one screen.
- IDE-like tools — editor, preview, and terminal, rearranged live.
- Trading consoles — dense, keyboard-driven panels in one viewport.
- Content sites — SEO tiles — this homepage is one.
Roadmap
These are planned directions, not shipped. React + DOM only today.
- Framework-agnostic core — a dependency-free vanilla TypeScript core so the tiling engine runs without any framework: the layout tree, the drag/FLIP state machine, and the self-healing recovery logic decoupled from React, ready to drive any view layer.
- First-class adapters for every major framework — React ships today; planned official adapters for Vue, Svelte, Solid, Angular, and standard Web Components, each a thin binding over the same vanilla core so behavior stays identical across frameworks.
- Canvas rendering backend — an optional canvas / GPU-accelerated render path for very high pane counts and animation-heavy scenes where DOM reflow is the bottleneck; the semantic DOM path stays the default and canvas is opt-in for density.
- Rust + WebAssembly core — porting the hot layout, drag, and geometry math to a Rust → WebAssembly core for deterministic, high-frame-rate behavior, unlocking more window-manager-like UX: snap zones, persistent session layouts, fully keyboard-driven tiling, and per-monitor-style multi-viewport arrangements.
Contributing
hypr-tiling is built in the open and welcomes collaboration — framework adapters, rendering backends, bug reports, and ideas from the roadmap above are all welcome. To get involved, email [email protected].
Links
- Homepage: https://hypr-tiling.n-uf.com
- Repository: https://github.com/n-uf/hypr-tiling
- Issues: https://github.com/n-uf/hypr-tiling/issues
License
Source-available under PolyForm Perimeter 1.0.1 — business use allowed, but no competing product built from this software.
