Skip to content
Layout

Workspace

A dockable workspace: tabbed panels split any way, dragged anywhere, floated into windows, maximized, and remembered — and nothing inside them ever remounts.

Walkthrough

1. Basic

Panels in tabbed groups, split any way. Drag a tab onto another group's edge to split it, onto its strip to join it, or to the workspace's own edge to dock along it. Drag a divider to resize. storageKey brings the arrangement back after a reload.

  • +page.svelte

  • +layout.svelte

  • api.ts

  • app.css

  • README.md

<h1>Hello</h1>
<p>Drag my tab somewhere.</p>
$ bun dev
  ready in 212 ms

Hello

Drag my tab somewhere.

<script lang="ts">	import { Workspace, WorkspacePanel, row, column, tabs, Text } from 'omaris';	import Icon from '$lib/patterns/shell/icon.svelte';​	const FILES = ['+page.svelte', '+layout.svelte', 'api.ts', 'app.css', 'README.md'];	const PAGE = '<h1>Hello</h1>\n<p>Drag my tab somewhere.</p>';	const API = 'export const load = () => ({ ok: true });';	const LOG = '$ bun dev\n  ready in 212 ms';</script>​{#snippet folder()}<Icon name="folder" />{/snippet}{#snippet code()}<Icon name="code" />{/snippet}{#snippet terminal()}<Icon name="terminal" />{/snippet}{#snippet eye()}<Icon name="eye" />{/snippet}​<div class="h-[30rem] w-full">	<Workspace		storageKey="docs-workspace-basic"		defaultLayout={row(			'files',			column(tabs('page', 'api'), 'terminal', { sizes: [65, 35] }),			'preview',			{ sizes: [22, 48, 30] }		)}	>		<WorkspacePanel id="files" title="Files" icon={folder}>			<ul class="flex flex-col p-2">				{#each FILES as file (file)}					<li class="flex h-9 items-center rounded-shape-sm px-3">						<Text variant="body-md">{file}</Text>					</li>				{/each}			</ul>		</WorkspacePanel>		<WorkspacePanel id="page" title="+page.svelte" icon={code}>			<pre class="p-4 font-mono text-body-sm leading-6">{PAGE}</pre>		</WorkspacePanel>		<WorkspacePanel id="api" title="api.ts" icon={code}>			<pre class="p-4 font-mono text-body-sm leading-6">{API}</pre>		</WorkspacePanel>		<WorkspacePanel id="terminal" title="Terminal" icon={terminal}>			<pre class="p-3 font-mono text-body-sm leading-6 text-muted-foreground">{LOG}</pre>		</WorkspacePanel>		<WorkspacePanel id="preview" title="Preview" icon={eye}>			<div class="grid h-full place-items-center p-6 text-center">				<div class="flex flex-col gap-1">					<Text variant="headline-sm">Hello</Text>					<Text variant="body-md" tone="muted">Drag my tab somewhere.</Text>				</div>			</div>		</WorkspacePanel>	</Workspace></div>

2. Dashboard

A dashboard people arrange for themselves. bind:open on each panel drives the checkboxes in the menu; a panel switched off remembers where it was and comes back there. bind:this gives the reset.

This week $48,210
increased by 12.4%
This week 1,284
increased by 6.1%
This week 9,312
decreased by 3.2%
  • Order #1043 paid

  • New customer: Lina

  • Refund issued for #1031

  • Order #1042 shipped

  • Stock low: Linen shirt

<script lang="ts">	import {		Workspace,		WorkspacePanel,		StatCard,		Button,		Menu,		MenuItem,		Text,		row,		column	} from 'omaris';​	let workspace = $state<Workspace>();	const panels = $state([		{ id: 'revenue', title: 'Revenue', open: true },		{ id: 'orders', title: 'Orders', open: true },		{ id: 'visitors', title: 'Visitors', open: true },		{ id: 'activity', title: 'Activity', open: true }	]);	/** The panel is the card already; the stat inside it needs no second one. */	const bare = 'h-full rounded-none border-0 bg-transparent shadow-none';	const ACTIVITY = [		'Order #1043 paid',		'New customer: Lina',		'Refund issued for #1031',		'Order #1042 shipped',		'Stock low: Linen shirt'	];</script>​<div class="flex w-full flex-col gap-3 p-3">	<div class="flex flex-wrap items-center gap-2">		<Menu closeOnSelect={false} label="Panels">			{#snippet trigger(props)}				<Button variant="tonal" size="sm" {...props}>Panels</Button>			{/snippet}			{#each panels as panel (panel.id)}				<MenuItem selected={panel.open} onclick={() => (panel.open = !panel.open)}>					{panel.title}				</MenuItem>			{/each}		</Menu>		<Button variant="text" size="sm" onclick={() => workspace?.reset()}>Reset layout</Button>	</div>​	<div class="h-[28rem]">		<Workspace			bind:this={workspace}			defaultLayout={column(row('revenue', 'orders', 'visitors'), 'activity', { sizes: [42, 58] })}		>			<WorkspacePanel id="revenue" title="Revenue" bind:open={panels[0].open}>				<StatCard					class={bare}					label="This week"					value={48210}					format={{ style: 'currency', currency: 'USD', maximumFractionDigits: 0 }}					delta={0.124}				/>			</WorkspacePanel>			<WorkspacePanel id="orders" title="Orders" bind:open={panels[1].open}>				<StatCard class={bare} label="This week" value={1284} delta={0.061} />			</WorkspacePanel>			<WorkspacePanel id="visitors" title="Visitors" bind:open={panels[2].open}>				<StatCard class={bare} label="This week" value={9312} delta={-0.032} />			</WorkspacePanel>			<WorkspacePanel				id="activity"				title="Activity"				bind:open={panels[3].open}				badge={ACTIVITY.length}			>				<ul class="flex flex-col divide-y divide-border">					{#each ACTIVITY as line (line)}						<li class="px-4 py-3"><Text variant="body-md">{line}</Text></li>					{/each}				</ul>			</WorkspacePanel>		</Workspace>	</div></div>

3. Floating and maximize

A panel can leave the tree for a floating window: drag it by its strip, size it from its edges, double-click the strip to dock it again. Hold Shift while dropping a tab to float it; double-click a docked strip — or its maximize button — to fill the workspace, and Escape to come back.

Background

Shape

<script lang="ts">	import { Workspace, WorkspacePanel, Button, Text, row, tabs } from 'omaris';​	let workspace = $state<Workspace>();</script>​<div class="flex w-full flex-col gap-3 p-3">	<div class="flex flex-wrap gap-2">		<Button variant="tonal" size="sm" onclick={() => workspace?.float('layers')}			>Float layers</Button		>		<Button variant="tonal" size="sm" onclick={() => workspace?.maximize('canvas')}>			Maximize canvas		</Button>	</div>	<div class="h-[28rem]">		<Workspace			bind:this={workspace}			defaultLayout={{				root: row('canvas', tabs('layers', 'history'), { sizes: [70, 30] }),				floating: [{ group: tabs('color'), x: 40, y: 72, width: 240, height: 200 }]			}}		>			<WorkspacePanel id="canvas" title="Canvas">				<div class="grid h-full place-items-center bg-surface-container-lowest">					<div class="size-32 rounded-shape-xl bg-primary-container"></div>				</div>			</WorkspacePanel>			<WorkspacePanel id="layers" title="Layers" class="p-3">				<Text variant="body-md">Background</Text>				<Text variant="body-md">Shape</Text>			</WorkspacePanel>			<WorkspacePanel id="history" title="History" class="p-3">				<Text variant="body-md" tone="muted">Nothing to undo.</Text>			</WorkspacePanel>			<WorkspacePanel id="color" title="Color" class="p-3">				<div class="grid grid-cols-4 gap-2">					{#each ['bg-primary', 'bg-secondary', 'bg-tertiary', 'bg-destructive'] as swatch (swatch)}						<div class="aspect-square rounded-shape-sm {swatch}"></div>					{/each}				</div>			</WorkspacePanel>		</Workspace>	</div></div>

4. Documents

Panels from data: every document is a WorkspacePanel in an {#each}, opened next to the others the moment it exists. An edited one shows the unsaved dot on its tab, and onclose returning false keeps it open until it is saved.

<script lang="ts">	import { Workspace, WorkspacePanel, Button, Textarea, row, tabs } from 'omaris';​	type Doc = { id: string; name: string; text: string; saved: string };	let docs = $state<Doc[]>([		{ id: 'readme', name: 'README.md', text: '# Hello', saved: '# Hello' },		{ id: 'todo', name: 'TODO.md', text: '- Ship it', saved: '- Ship it' }	]);	let next = 1;​	function create() {		const n = next++;		docs.push({ id: `untitled-${n}`, name: `Untitled ${n}.md`, text: '', saved: '' });	}</script>​<div class="flex w-full flex-col gap-3 p-3">	<div><Button variant="tonal" size="sm" onclick={create}>New document</Button></div>	<div class="h-[26rem]">		<Workspace defaultLayout={row(tabs('readme', 'todo'))}>			{#each docs as doc (doc.id)}				<WorkspacePanel					id={doc.id}					title={doc.name}					open					placement={{ with: 'readme' }}					modified={doc.text !== doc.saved}					onclose={() => doc.text === doc.saved}					class="flex flex-col gap-2 p-3"				>					<Textarea label={doc.name} bind:value={doc.text} rows={8} />					<div class="mt-auto flex justify-end">						<Button							size="sm"							disabled={doc.text === doc.saved}							onclick={() => (doc.saved = doc.text)}						>							Save						</Button>					</div>				</WorkspacePanel>			{/each}		</Workspace>	</div></div>

5. Flush

variant="flush": edge to edge with hairlines between, the density of an IDE. Everything else — the dragging, the docking, the keyboard — is the same.

load()

actions

export async function load() {
  return { items: await db.items() };
}

No problems.

<script lang="ts">	import { Workspace, WorkspacePanel, Text, row, column } from 'omaris';</script>​<div class="h-96 w-full">	<Workspace		variant="flush"		defaultLayout={row('outline', column('source', 'problems', { sizes: [70, 30] }), {			sizes: [25, 75]		})}	>		<WorkspacePanel id="outline" title="Outline" class="p-3">			<Text variant="body-md">load()</Text>			<Text variant="body-md">actions</Text>		</WorkspacePanel>		<WorkspacePanel id="source" title="+page.server.ts">			<pre				class="p-4 font-mono text-body-sm leading-6">{'export async function load() {\n  return { items: await db.items() };\n}'}</pre>		</WorkspacePanel>		<WorkspacePanel id="problems" title="Problems" badge={0} class="p-3">			<Text variant="body-md" tone="muted">No problems.</Text>		</WorkspacePanel>	</Workspace></div>

6. Overridden

Every part is reachable. A wider gap and a sharper radius through the custom properties, the strip and the front tab through classes, a fixed height from class, and padding on a panel's own content box. locked keeps the arrangement as it is: tabs switch, nothing moves.

A locked layout, restyled.

Left

Right

<script lang="ts">	import { Workspace, WorkspacePanel, Text, row, column } from 'omaris';</script>​<div class="w-full p-3">	<Workspace		locked		class="h-80 rounded-shape-xl [--workspace-gap:--spacing(3)] [--workspace-radius:var(--radius-shape-sm)]"		classes={{			header: 'bg-primary-container text-primary-container-foreground',			tab: 'data-active:bg-primary data-active:text-primary-foreground'		}}		defaultLayout={column('summary', row('left', 'right'), { sizes: [40, 60] })}	>		<WorkspacePanel id="summary" title="Summary" class="bg-surface-container-low p-4">			<Text variant="body-md">A locked layout, restyled.</Text>		</WorkspacePanel>		<WorkspacePanel id="left" title="Left" class="p-4"><Text>Left</Text></WorkspacePanel>		<WorkspacePanel id="right" title="Right" class="p-4"><Text>Right</Text></WorkspacePanel>	</Workspace></div>