Skip to content
omaris

Layout

Workspace

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

import { Workspace } from 'omaris'
Learn

Examples

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>

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>

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>

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>

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>

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>

When to use it

Use it for

  • A screen people arrange for themselves: an editor with its files, terminal and preview; an admin console of logs, charts and a runbook; a dashboard whose cards someone drags into the order they read them.
  • Tools with many views and not enough room for all of them at once — tabs stack what does not fit, splits show what must be side by side, and a floating window holds what is needed for a minute.
  • Content that must survive being moved. A panel's content is mounted once and never re-parented, so an iframe, a video, a form or a scrolled list keeps its state through every drag.
  • An arrangement that should come back tomorrow: storageKey saves it, or bind:layout hands you the JSON to keep on a server.
  • Panels that come from data: a WorkspacePanel in an {#each} with open appears next to the others the moment it exists.

Not for

  • One sidebar beside one content area with a draggable divider → Resizable.
  • Views of one thing, switched one at a time, that nobody rearranges → Tabs.
  • The frame of an app — its navigation and the column that scrolls → App Shell. Put the workspace inside it.
  • Cards in a grid that people only reorder → Kanban, or a plain CSS grid.
  • A panel that slides in over the page and leaves again → Sheet.

Do

  • Give it a height. It fills its parent, which needs h-… or a flex column with min-h-0.
  • Write defaultLayout with row, column and tabs — a string is a panel in a group of its own, an array is a group of tabs, and a trailing { sizes: [30, 70] } sets the shares. It is also what the server renders.
  • Give every panel an id that means something ('terminal', not 'p3') and keep it stable: saved layouts refer to panels by id.
  • Put class="p-4" on a WorkspacePanel for padding. Its content box scrolls on its own.
  • Use bind:open for a toolbar toggle or a menu of checkboxes. A panel switched off remembers its place.
  • Pause expensive work in a hidden tab with getWorkspacePanel().shown.

Don't

  • Reorder an {#each} of panels to change the layout. The layout says where panels are; the markup only declares them. Move them with move() or let people drag them.
  • Use the same storageKey for two workspaces. They overwrite each other.
  • Make every panel closable={false} and movable={false} — that is a static grid, and a grid is simpler.
  • Rely on the tab strip alone on a phone. Below compactBelow the workspace becomes one stack of tabs; keep the most-used panel first in the layout.

Quick reference

variant
  • cards (default)
  • flush

API

Workspace

A dockable workspace: panels in tabbed groups, split any way, dragged anywhere, floated into windows, maximized, and remembered.

Every group is drawn absolutely from pure maths on the layout tree, and every panel's content is one element that never leaves its place in the DOM — the workspace only moves the box it is drawn in. So nothing a panel holds is ever unmounted by a rearrangement: an iframe keeps its page, a video keeps playing, a form keeps what was typed, a list its scroll. And because the geometry is CSS rather than measurement, a group flies to its new place under a transition in the same frame the tree changes.

import { Workspace } from 'omaris'

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

root
No description in the source yet.
stage
The area groups are drawn in, inset half a gap so the outer margin matches the inner.
group
A group of tabs: the card behind a panel.
header
The strip along the top of a group: tabs, then tools.
tabs
No description in the source yet.
tab
One tab: the pill around the label and the close button.
tabButton
The label part of a tab — the role="tab" button.
close
The × on a tab, and the unsaved dot that stands in for it.
badge
No description in the source yet.
tools
The panel's own actions, maximize and the menu, at the end of the header.
sash
The divider between two groups — and the thing you drag.
preview
Where a dragged tab will land, drawn over the workspace while you drag.
caret
The line between two tabs where a dropped tab goes.
ghost
What follows the pointer while a tab is dragged.
handle
A floating window's resize edges and corners.
empty
What shows when nothing is open.

Props

layout bindable
WorkspaceLayout

The layout, bindable. Leave it out and the workspace keeps its own, starting from defaultLayout; bind it to save it yourself, or to switch between presets by assigning one.

defaultLayout
WorkspaceLayoutInput

Where the panels start, and what reset() goes back to. Write it with row, column and tabs: row('files', column('editor', 'terminal'), { sizes: [20, 80] }). Left out, every panel sits side by side in the order declared.

storageKey
string

Saves the layout in localStorage under this key and restores it on load.

variant

Defaults to 'cards'

WorkspaceVariant
cards
MD3 surfaces: rounded cards with a gap between them.
flush
Edge to edge with hairlines between, the way an IDE packs its panes.
compactBelow

Defaults to 600

number

Below this width, in px, every panel becomes a tab in one full-size stack — the workspace on a phone. 0 never stacks.

locked

Defaults to false

boolean

Nothing moves: no dragging, resizing, closing or floating. Tabs still switch.

floating

Defaults to true

boolean

Panels may leave the tree for floating windows.

maximizable

Defaults to true

boolean

Groups may be maximized to fill the workspace.

labels
Partial<WorkspaceLabels>
class
string
classes
WorkspaceClasses

Per-part Tailwind overrides. class still covers the root.

empty
Snippet<[{ closed: string[]; show: (id: string) => void }]>

What shows when every panel is closed. It gets the closed panels' ids and a way to open one.

onlayoutchange
(layout: WorkspaceLayout) => void

Fires after every change the person makes — a move, a resize, a close.

onactivate
(id: string) => void

A panel was brought to the front.

children
Snippet

The WorkspacePanels.

WorkspacePanel

One panel of a Workspace: a title for its tab, and the content.

The content is rendered here, once, and stays here — the workspace only moves the box it is drawn in. That is why dragging a panel to another group, into a window, or out to an edge never remounts it. A panel's content mounts the first time it is shown and stays mounted while it is in the layout; keepAlive keeps it mounted after it is closed as well.

import { WorkspacePanel } from 'omaris'

Props

id required
string

Names the panel in the layout. Unique within the workspace.

title required
string

What its tab says.

icon
Snippet

Leading icon for the tab.

badge
number | string | boolean

A count or short word after the title, or true for a dot.

modified

Defaults to false

boolean

Unsaved changes: a dot where the close button sits until it is hovered.

closable

Defaults to true

boolean

Has a close button, and Delete closes it.

movable

Defaults to true

boolean

May be dragged, split off and moved with the keyboard.

floatable

Defaults to true

boolean

May go into a floating window.

minWidth

Defaults to 160

number

Smallest width its group may be dragged to, in px.

minHeight

Defaults to 80

number

Smallest height of its content, in px — the tab strip comes on top.

keepAlive

Defaults to false

boolean

Stay mounted, hidden, while closed — for a panel that is slow to build.

open bindable
boolean

Bindable: whether the panel is in the layout. Set it to open or close the panel from outside — a toolbar toggle, a menu of checkboxes.

placement
WorkspacePlacement

Where it goes when open is set and it has no remembered place.

actions
Snippet

Buttons for the group's header while this panel is on show — a refresh, a filter.

onclose
() => boolean | void | Promise<boolean | void>

Asked before closing: return false, or a promise of it, to keep the panel.

class
string

Covers the content box: padding, a background, a layout.

children
Snippet