npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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 createPersistedTilingLayout saves to localStorage: 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

Install

pnpm add @n-uf/hypr-tiling react react-dom
npm install @n-uf/hypr-tiling react react-dom
yarn add @n-uf/hypr-tiling react react-dom

react 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: ./engine alone needs neither, so an engine-only project installs without them
  • Tailwind scanning: required (content glob 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-resize cursors 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 Tailwind cursor-* 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 tree

load() 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; returns removedTileIds shown only there), switchWorkspace, cycleWorkspace, setWorkspaceLayout, moveLeafToWorkspace(set, leafId, to, placement?), showInWorkspace, hideFromWorkspace, tile-keyed moveTileToWorkspace, showTileInWorkspace, hideTileFromWorkspace, removeTile, revealTile, resetWorkspaceLayout, resetWorkspaceSet, workspaceSetEquals, queryWorkspaceSet, workspaceSetIssues, repairWorkspaceSet. TilingWorkspacePlacement adds { 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 (TilingLeafPin did not ship).
  • Renderer — workspaces + onWorkspacesChange replace layout + onLayoutChange. Uncontrolled focus / maximize are remembered per workspace; a drag in flight is cancelled when the active workspace changes; an empty workspace renders renderEmptyWorkspace(workspace).
  • tiles is 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 calls onIntegrityIssues(issues) whenever the issue fingerprint changes (once more with [] when it clears); "seat-in-active" runs repairWorkspaceSet once per set identity (orphans seated in the active tree with TILING_DEFAULT_WORKSPACE_PLACEMENT, unknown seats pruned), emits the repaired set on onWorkspacesChange, and reports what it repaired; "ignore" does neither. mintLeafId?: (tileId) => string names a minted seat (default `leaf:${tileId}`).
    • inactiveWorkspaces?: "unmount" | "keep-mounted" (default "unmount") — with paneIdentity: "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 under paneIdentity: "slot".
    • renderTile receives workspaceId (the workspace the pane is painted in) and seatCount (how many workspaces seat the tile) — single-layout mode reports TILING_MAIN_WORKSPACE_ID / 1.
  • Drop on a tab — a kind: "workspace-tab" externalDragHover (what tabs.rendererProps resolves for you) settles by moveLeafToWorkspace with no host claim; onMoveLeaf(leafId, from, to) reports it. With interaction.workspaces.followMovedLeaf: true the same onWorkspacesChange is already switched to the destination (onWorkspaceSwitch({ via: "tab-drop" })) — do not switch again from onMoveLeaf. Under paneIdentity: "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", roving tabIndex, Arrow keys / Home / End move focus, Enter / Space activate, F2 → onRenameRequest, Delete → onCloseRequest. Unstyled until a theme supplies workspaceTabs / 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's layout and name from the seed workspace of the same id (workspaceId omitted → set.activeId). If the seed has no such id, that workspace's layout becomes null and its name is kept.
  • resetWorkspaceSet(set, defaults) / { kind: "reset-workspaces" } — replace the whole set from the seed, keeping set.activeId when that id exists in the seed.
  • Controller: ctl.reset("workspace" | "all", workspaceId?) commits immediately (same path as switch, not tree-debounced) and returns false when nothing changed or defaults is omitted. ctl.atDefaults is true when reset("all") would change nothing — every workspace matches the seed, whichever one is active (false with 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 groupDropTargetRef when 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), overlayPortalContainer for a theme-scoped drag cursor, and live theme switching with no remount.
  • Titlebar-only pane collapse — opt-in paneTitleBarControls.collapse pins a pane to its chrome extent; resizeFloor defaults to "chrome"; onPaneCollapsedChange / minBBoxPx travel 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, and onExternalDrop claim-before-settle. In set mode a kind: "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 — createPersistedTilingLayout saves the tree (localStorage by default) and heals it through assertLayoutIntegrity / repairLayout.
  • Self-healing drag recovery — a frame-deadline backstop, an idle watchdog, transient-style teardown, and a visibilitychange reconcile 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

License

Source-available under PolyForm Perimeter 1.0.1 — business use allowed, but no competing product built from this software.