Split Workspace
alphav0.2.0Splittable, mergeable canvas of editor areas — a dynamic layout primitive for dashboards, dev tools, and data apps.
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
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/split-workspaceAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/split-workspace-fixturesCLI 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
"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+Arrowon a focused area — nudge the adjacent boundary.- Click a divider, then
Arrowkeys — 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. maxSplitDepthis 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+onLayoutChangefor controlled mode (consumer owns persistence). Omitlayoutand passdefaultLayoutfor uncontrolled. Note:onLayoutChangefires 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-frameonResizeand a debouncedonLayoutChange. onError(v0.1.2) surfaces tree-validation issues (unregisteredcomponentId, duplicate ids, bad ratios) alongside the existingconsole.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