Skip to content
ilinxa/pro-ui

Split Workspace

alphav0.2.0

Splittable, mergeable canvas of editor areas — a dynamic layout primitive for dashboards, dev tools, and data apps.

Category: LayoutUpdated: 2026-08-11Created: 2026-04-27Author: ilinxa

Context

SplitWorkspace is the registry's foundational layout primitive: a single root container that tiles its viewport with rectangular editor areas (no float, no overlap). Each area picks from a consumer-supplied registry of components via a top-left dropdown. Users split areas by dragging a corner inward, merge by dragging a corner out into a neighboring area, and resize by dragging shared edges. The component is content-agnostic — consumers register what's pluggable. Designed for web apps where one fixed layout never fits everyone's workflow.

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/split-workspace

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/split-workspace-fixtures

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Try splitting an area by dragging from a corner, resize via the boundary, or click a divider and press Arrow keys to nudge it (new in v0.1.2). Mobile widths collapse to a card stack whose item height you control via cardStackItemHeight — 420px in this demo. Validation issues surface via onErrorbelow the canvas.

Demo source

demo.tsxtsx
"use client"; import { useEffect, useMemo, useState } from "react";import {  AlertTriangleIcon,  ClockIcon,  HashIcon,  NotebookIcon,  TableIcon,} from "lucide-react";import { Badge } from "@/components/ui/badge";import { useAreaContext } from "./hooks/use-area-context";import { SplitWorkspace } from "./split-workspace";import {  DEMO_INITIAL_LAYOUT,  DEMO_PRESETS,  DEMO_TABLE_ROWS,  type DemoTableRow,} from "./dummy-data";import type { SplitWorkspaceComponent } from "./types"; function NotesPanel() {  const [text, setText] = useState(    "Take notes here. State persists across resize and split — try resizing the area or splitting it from a corner.",  );  return (    <div className="flex h-full flex-col gap-2 p-3">      <textarea        value={text}        onChange={(e) => setText(e.target.value)}        className="min-h-0 flex-1 resize-none rounded-md border border-border bg-background p-2 font-mono text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"      />      <p className="text-[10px] text-muted-foreground">        {text.length} characters      </p>    </div>  );} function ClockPanel() {  const [now, setNow] = useState(() => new Date());  useEffect(() => {    const id = window.setInterval(() => setNow(new Date()), 1000);    return () => window.clearInterval(id);  }, []);  const ctx = useAreaContext();  return (    <div className="flex h-full flex-col items-center justify-center gap-2 p-3">      <span className="font-mono text-3xl tabular-nums text-foreground">        {now.toLocaleTimeString()}      </span>      <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground">        {ctx.width.toFixed(0)} × {ctx.height.toFixed(0)} px      </span>    </div>  );} function CounterPanel() {  const [count, setCount] = useState(0);  return (    <div className="flex h-full flex-col items-center justify-center gap-3 p-3">      <span className="font-mono text-4xl tabular-nums text-foreground">        {count}      </span>      <div className="flex items-center gap-2">        <button          type="button"          onClick={() => setCount((c) => c - 1)}          className="rounded-md border border-border bg-background px-3 py-1 text-xs font-medium text-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"        >          −        </button>        <button          type="button"          onClick={() => setCount(0)}          className="rounded-md border border-border bg-background px-3 py-1 text-xs font-medium text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"        >          reset        </button>        <button          type="button"          onClick={() => setCount((c) => c + 1)}          className="rounded-md border border-border bg-background px-3 py-1 text-xs font-medium text-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"        >          +        </button>      </div>      <p className="max-w-56 text-center text-[10px] text-muted-foreground">        Increment, then split this area from a corner — the original keeps its        count, the new sibling starts at 0.      </p>    </div>  );} function statusVariant(  status: DemoTableRow["status"],): "default" | "secondary" | "destructive" {  if (status === "Done") return "default";  if (status === "In progress") return "secondary";  return "destructive";} function TablePanel() {  return (    <div className="flex h-full flex-col p-3">      <table className="w-full text-xs">        <thead>          <tr className="border-b border-border text-left text-muted-foreground">            <th className="py-1 pr-2 font-medium">Task</th>            <th className="py-1 pr-2 font-medium">Owner</th>            <th className="py-1 font-medium">Status</th>          </tr>        </thead>        <tbody>          {DEMO_TABLE_ROWS.map((row) => (            <tr key={row.id} className="border-b border-border/60 last:border-0">              <td className="py-1.5 pr-2 text-foreground">{row.task}</td>              <td className="py-1.5 pr-2 text-muted-foreground">{row.owner}</td>              <td className="py-1.5">                <Badge variant={statusVariant(row.status)} className="text-[10px]">                  {row.status}                </Badge>              </td>            </tr>          ))}        </tbody>      </table>    </div>  );} const components: SplitWorkspaceComponent[] = [  {    id: "notes",    name: "Notes",    category: "Tools",    icon: <NotebookIcon className="size-3" />,    render: () => <NotesPanel />,  },  {    id: "clock",    name: "Clock",    category: "Tools",    icon: <ClockIcon className="size-3" />,    render: () => <ClockPanel />,  },  {    id: "counter",    name: "Counter",    category: "Tools",    icon: <HashIcon className="size-3" />,    render: () => <CounterPanel />,  },  {    id: "data-table",    name: "Data Table",    category: "Data",    icon: <TableIcon className="size-3" />,    render: () => <TablePanel />,  },]; export default function SplitWorkspaceDemo() {  const [errors, setErrors] = useState<string[]>([]);  const handleError = useMemo(    () => (next: string[]) => setErrors(next),    [],  );  return (    <div className="flex flex-col gap-3">      <p className="text-xs text-muted-foreground">        Try splitting an area by dragging from a corner, resize via the        boundary, or <strong>click a divider and press Arrow keys</strong> to        nudge it (new in v0.1.2). Mobile widths collapse to a card stack        whose item height you control via <code>cardStackItemHeight</code> —        420px in this demo. Validation issues surface via <code>onError</code>        below the canvas.      </p>      <div className="h-140 w-full">        <SplitWorkspace          components={components}          defaultComponentId="notes"          defaultLayout={DEMO_INITIAL_LAYOUT}          presets={DEMO_PRESETS}          cardStackItemHeight={420}          onError={handleError}          aria-label="SplitWorkspace demo"        />      </div>      {errors.length > 0 ? (        <div          role="alert"          className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-xs text-destructive"        >          <AlertTriangleIcon className="mt-0.5 size-3.5 shrink-0" aria-hidden />          <div>            <p className="font-medium">SplitWorkspace validation</p>            <ul className="mt-1 ml-3 list-disc space-y-0.5">              {errors.map((msg, i) => (                <li key={i}>{msg}</li>              ))}            </ul>          </div>        </div>      ) : null}    </div>  );} 

Usage

When to use

Reach for SplitWorkspacewhen one fixed layout never fits every user's workflow — dashboards with diverse widgets, dev tools that mix code / preview / console, data-exploration apps that need side-by-side views. Users split areas with corner drags, swap each area's content from a registry, and save common arrangements as presets.

Basic example

import { SplitWorkspace, type SplitWorkspaceComponent } from "@/components/workspace"

const components: SplitWorkspaceComponent[] = [
  { id: "chart",  name: "Chart",  category: "Data",  render: () => <ChartPanel /> },
  { id: "table",  name: "Table",  category: "Data",  render: () => <TablePanel /> },
  { id: "filter", name: "Filter", category: "Tools", render: () => <FilterPanel /> },
]

export function Example() {
  return (
    <div className="h-screen w-full">
      <SplitWorkspace
        components={components}
        defaultComponentId="chart"
      />
    </div>
  )
}

Inside a registered component

Every component's render() runs inside an area context. Call useAreaContext()to read live dimensions, the area's id, and whether it currently holds focus.

import { useAreaContext } from "@/components/workspace"

function ChartPanel() {
  const { width, height, isFocused } = useAreaContext()
  return <Chart width={width} height={height} highlight={isFocused} />
}

Gestures (desktop)

  • Corner-drag inward — split the area; orientation is inferred from the drag direction.
  • Corner-drag outward into a neighbor — merge; the neighbor is replaced.
  • Drag a shared edge — resize. Boundaries clamp to minAreaSize.
  • Top-left dropdown— change the area's component.

Keyboard alternatives

  • Tab — focus an area.
  • Header chevron menu — split / merge / pick a component (works without a mouse).
  • Alt+Shift+Arrow on a focused area — nudge the adjacent boundary.
  • Click a divider, then Arrow keys — resize the divider directly (new in v0.1.2).

Notes

  • State preservation: splitting keeps the original area's component instance intact (state, scroll position, focus); the new sibling mounts fresh. Merging and switching component-id remount.
  • Mobile (viewport width below breakpoints.mobile) renders as a 1-column card stack regardless of the underlying tree. Tree state is preserved internally and restored when widening back.
  • maxSplitDepth is a hard cap, applied per leaf and configurable per breakpoint (default { mobile: 0, tablet: 3, desktop: 7 }). When the originating leaf is at cap, splitting is inert (no preview, no toast); merging into a neighbor on the same gesture still works. Devtools console logs once per session.
  • Set a height on the wrapper (h-[600px], h-screen, flex-1, etc.) — SplitWorkspace fills its container.
  • Pass layout + onLayoutChange for controlled mode (consumer owns persistence). Omit layout and pass defaultLayout for uncontrolled. Note: onLayoutChange fires per animation frame during edge-drag (~60Hz) — debounce in your handler if you persist to storage. v0.2.0 will split this into a per-frame onResize and a debounced onLayoutChange.
  • onError (v0.1.2) surfaces tree-validation issues (unregistered componentId, duplicate ids, bad ratios) alongside the existing console.error.
  • cardStackItemHeight (v0.1.2) overrides the mobile card height (default 320px).

Features

  • Splittable / mergeable canvas via corner-drag gestures (with keyboard parity)
  • Per-area component registry with a dropdown selector
  • Per-breakpoint hard cap on split depth (preventive + adaptive)
  • Responsive collapse to a 1-column card stack on mobile
  • State preservation: splitting keeps the original area's component instance and state
  • Saved presets switchable via tabs

Tags

split-workspacelayoutsplitpanelstilingdashboard

Dependencies

shadcn primitives: dropdown-menu, scroll-area, tabs
npm peer deps: lucide-react@^1.11.0