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' 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.
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:
storageKeysaves it, orbind:layouthands you the JSON to keep on a server. - Panels that come from data: a
WorkspacePanelin an{#each}withopenappears 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 withmin-h-0. - Write
defaultLayoutwithrow,columnandtabs— 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
idthat means something ('terminal', not'p3') and keep it stable: saved layouts refer to panels by id. - Put
class="p-4"on aWorkspacePanelfor padding. Its content box scrolls on its own. - Use
bind:openfor 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 withmove()or let people drag them. - Use the same
storageKeyfor two workspaces. They overwrite each other. - Make every panel
closable={false}andmovable={false}— that is a static grid, and a grid is simpler. - Rely on the tab strip alone on a phone. Below
compactBelowthe 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 bindableWorkspaceLayout 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 bindableboolean 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