Resizable, draggable tab layout for Svelte 5, built around snippets.
npm install horizon-layout
import { HorizonLayout, LayoutItem } from 'horizon-layout';
import 'horizon-layout/horizon-layout.css';
<script lang="ts">
import { HorizonLayout, LayoutItem, type LayoutConfig } from 'horizon-layout';
import 'horizon-layout/horizon-layout.css';
let config = $state<LayoutConfig | null>(null);
</script>
<HorizonLayout id="my-layout" bind:config>
<LayoutItem id="welcome" title="Welcome">
<div>Welcome tab</div>
</LayoutItem>
<LayoutItem id="editor" title="Editor">
<div>Editor tab</div>
</LayoutItem>
</HorizonLayout>
config starts as null and the component builds a default layout from registered items. Pass an initial value to control the starting layout explicitly.
horizon-layout separates two concerns:
LayoutItem components register their id, title, and children snippet with the nearest HorizonLayout at runtime.On mount, the config is normalized against the registered ids: any missing ids are appended to the first available panel, and any unknown ids are removed. Layout mutations (drag, split, resize) update the config immutably via plain snapshots rather than Svelte proxy state.
| Gesture | Result |
|---|---|
| Drag tab → another panel's tab strip | Merges tab into that panel |
| Drag tab → panel content area edge | Splits panel on the nearest edge |
| Click maximize button | Expands the active tab; state persists through reload |
HorizonLayout| Prop | Type | Default | Description |
|---|---|---|---|
id |
string |
— | Required. Unique identifier for this layout instance. Used as the localStorage key and to isolate drag events when multiple layouts exist on the same page. |
config |
LayoutConfig | null |
— | Required. Use bind:config to receive updates. Pass null to let the component build a default layout. |
persist |
boolean |
true |
When true, the layout (including maximize state) is saved to and restored from localStorage under the id key. |
dragAndDrop |
boolean |
true |
Enables or disables tab drag-and-drop. |
popoutEnabled |
boolean |
false |
Default popout permission for tabs that do not set controls.canPopout. |
onPopoutTab |
(tabId: string, panelId: string) => void |
— | Optional override for custom popout handling. When omitted, the library manages popup windows for you. |
onTabPopout |
(tabId: string, panelId: string) => void |
— | Callback fired when a tab popout action is triggered. |
onTabMaximize |
(tabId: string, panelId: string) => void |
— | Callback fired when a tab becomes maximized. |
onTabClose |
(tabId: string, panelId: string) => void |
— | Callback fired when a tab is closed from the layout. |
maximizedTabId |
string | null |
null |
Use bind:maximizedTabId to read or control which tab is currently maximized. Any falsy value restores the normal layout. |
resizeConstraints |
ResizeConstraints |
— | Min/max percentage constraints for resizing and split creation. |
popoutButton |
Snippet<[TabActionControlProps]> |
— | Snippet rendered for per-tab popout controls. Only shown for tabs allowed to pop out. |
closeButton |
Snippet<[TabActionControlProps]> |
— | Snippet rendered for per-tab close controls. Only shown for tabs allowed to close. |
maximizeButton |
Snippet<[MaximizeControlProps]> |
— | Snippet rendered as the maximize button. Both this and restoreButton must be provided to enable maximize mode. |
restoreButton |
Snippet<[MaximizeControlProps]> |
— | Snippet rendered as the restore button. Both this and maximizeButton must be provided to enable maximize mode. |
class |
string |
— | Extra class added to the root .horizon-layout element. |
children |
Snippet |
— | Should contain LayoutItem components. |
LayoutItem| Prop | Type | Description |
|---|---|---|
id |
string |
Stable id used in the layout config to identify this tab. |
title |
string |
Label shown in the tab strip. |
controls |
LayoutItemControls |
Per-tab capability flags for popout, maximize, and close actions. |
children |
Snippet |
The tab content. |
ResizeConstraintsinterface ResizeConstraints {
minWidthPercent?: number; // applies to 'row' splits
maxWidthPercent?: number;
minHeightPercent?: number; // applies to 'column' splits
maxHeightPercent?: number;
}
The same constraints govern both resize gestures and whether a new split is allowed.
TabActionControlPropsinterface TabActionControlProps {
panelId: string; // the panel that currently contains the tab
tabId: string | null; // the active tab this control applies to
action: () => void; // call to run the control action
}
MaximizeControlPropsinterface MaximizeControlProps extends TabActionControlProps {}
LayoutItemControlsinterface LayoutItemControls {
canPopout?: boolean;
canMaximize?: boolean;
canClose?: boolean;
}
interface LayoutConfig {
root: LayoutNodeConfig;
maximizedTabId?: string | null; // persisted alongside the layout tree
closedTabIds?: string[]; // tabs removed from the visible layout
}
type LayoutNodeConfig = LayoutPanelConfig | LayoutSplitConfig;
interface LayoutPanelConfig {
id: string;
type: 'panel';
tabs: string[]; // ordered list of LayoutItem ids
activeTab: string | null;
}
interface LayoutSplitConfig {
id: string;
type: 'split';
direction: 'row' | 'column'; // 'row' = side-by-side, 'column' = stacked
sizes: number[]; // percentage array, aligned with children
children: LayoutNodeConfig[];
}
A 70/30 horizontal split with an editor on the left and a file tree on the right.
<script lang="ts">
import { HorizonLayout, LayoutItem, type LayoutConfig } from 'horizon-layout';
import 'horizon-layout/horizon-layout.css';
let config = $state<LayoutConfig>({
root: {
id: 'root-split',
type: 'split',
direction: 'row',
sizes: [70, 30],
children: [
{
id: 'main-panel',
type: 'panel',
tabs: ['editor', 'preview'],
activeTab: 'editor'
},
{
id: 'side-panel',
type: 'panel',
tabs: ['files'],
activeTab: 'files'
}
]
}
});
</script>
<HorizonLayout id="ide-layout" bind:config>
<LayoutItem id="editor" title="Editor">
<textarea>// your code here</textarea>
</LayoutItem>
<LayoutItem id="preview" title="Preview">
<iframe src="/preview" title="Preview" />
</LayoutItem>
<LayoutItem id="files" title="Files">
<ul><!-- file tree --></ul>
</LayoutItem>
</HorizonLayout>
An editor on the left, with a terminal stacked below a preview pane on the right.
<script lang="ts">
let config = $state<LayoutConfig>({
root: {
id: 'outer-split',
type: 'split',
direction: 'row',
sizes: [60, 40],
children: [
{
id: 'editor-panel',
type: 'panel',
tabs: ['editor'],
activeTab: 'editor'
},
{
id: 'right-split',
type: 'split',
direction: 'column',
sizes: [60, 40],
children: [
{
id: 'preview-panel',
type: 'panel',
tabs: ['preview'],
activeTab: 'preview'
},
{
id: 'terminal-panel',
type: 'panel',
tabs: ['terminal'],
activeTab: 'terminal'
}
]
}
]
}
});
</script>
<HorizonLayout id="ide-three-panel" bind:config>
<LayoutItem id="editor" title="Editor"><!-- ... --></LayoutItem>
<LayoutItem id="preview" title="Preview"><!-- ... --></LayoutItem>
<LayoutItem id="terminal" title="Terminal"><!-- ... --></LayoutItem>
</HorizonLayout>
Use resizeConstraints to prevent panels from being resized too small or too large.
<HorizonLayout
id="my-layout"
bind:config
resizeConstraints={{
minWidthPercent: 20,
maxWidthPercent: 80,
minHeightPercent: 15,
maxHeightPercent: 85
}}
>
<LayoutItem id="main" title="Main"><!-- ... --></LayoutItem>
<LayoutItem id="sidebar" title="Sidebar"><!-- ... --></LayoutItem>
</HorizonLayout>
Pass a popoutButton snippet and enable popout per tab with controls.canPopout. The built-in popout behavior removes the tab from the layout while the popup is open and restores it when the popup closes.
<script lang="ts">
import {
HorizonLayout,
LayoutItem,
type LayoutConfig,
type TabActionControlProps
} from 'horizon-layout';
import 'horizon-layout/horizon-layout.css';
let config = $state<LayoutConfig | null>(null);
</script>
<HorizonLayout id="my-layout" bind:config>
{#snippet popoutButton({ action }: TabActionControlProps)}
<button onclick={action} aria-label="Pop out tab">↗</button>
{/snippet}
<LayoutItem id="editor" title="Editor" controls={{ canPopout: true }}>
<textarea>console.log('hello');</textarea>
</LayoutItem>
<LayoutItem id="preview" title="Preview">
<div>Preview output</div>
</LayoutItem>
</HorizonLayout>
Provide onPopoutTab if you want to intercept the popout action and manage your own windowing behavior.
<script lang="ts">
function handlePopoutTab(tabId: string, panelId: string) {
console.log('Pop out', { tabId, panelId });
}
function handleTabPopout(tabId: string, panelId: string) {
console.log('Popout requested', { tabId, panelId });
}
</script>
<HorizonLayout
id="my-layout"
bind:config
onTabPopout={handleTabPopout}
onPopoutTab={handlePopoutTab}
>
{#snippet popoutButton({ action })}
<button onclick={action}>Pop out</button>
{/snippet}
<LayoutItem id="editor" title="Editor" controls={{ canPopout: true }}><!-- ... --></LayoutItem>
</HorizonLayout>
When onPopoutTab is provided, the library does not open its managed popup window and instead delegates the action to your callback.
Pass a closeButton snippet and enable close per tab with controls.canClose. Closed tabs are tracked in config.closedTabIds, so they stay removed across persistence and normalization.
<HorizonLayout id="my-layout" bind:config onTabClose={(tabId) => console.log('Closed', tabId)}>
{#snippet closeButton({ action })}
<button onclick={action} aria-label="Close tab">×</button>
{/snippet}
<LayoutItem id="editor" title="Editor" controls={{ canClose: true }}><!-- ... --></LayoutItem>
</HorizonLayout>
Pass both maximizeButton and restoreButton snippets to enable maximize mode. Each snippet receives the current panelId, active tabId, and an action callback. Maximize can then be enabled or disabled per tab with controls.canMaximize.
<HorizonLayout
id="my-layout"
bind:config
onTabMaximize={(tabId) => console.log('Maximized', tabId)}
>
{#snippet maximizeButton({ action })}
<button onclick={action} aria-label="Maximize tab">🗖</button>
{/snippet}
{#snippet restoreButton({ action })}
<button onclick={action} aria-label="Restore panel">🗗</button>
{/snippet}
<LayoutItem id="editor" title="Editor" controls={{ canMaximize: true }}><!-- ... --></LayoutItem>
<LayoutItem id="preview" title="Preview"><!-- ... --></LayoutItem>
</HorizonLayout>
Maximize mode is opt-in: if either snippet is omitted, tabs cannot be maximized. A tab with controls.canMaximize={false} will not show the maximize control, but it can still be restored if it was already maximized. The maximized tab id is written into the persisted config, so a page reload restores the maximized state.
bind:maximizedTabId exposes the current maximize state as a bindable. You can trigger maximize from anywhere by setting it to a tab id directly.
<script lang="ts">
import { HorizonLayout, LayoutItem, type LayoutConfig } from 'horizon-layout';
let config = $state<LayoutConfig | null>(null);
let maximizedTabId = $state<string | null>(null);
function onKeydown(e: KeyboardEvent) {
if (!config) return;
if (e.ctrlKey && e.key === 'm') {
maximizedTabId = 'editor';
}
if (e.key === 'Escape') {
maximizedTabId = null; // restore
}
}
</script>
<svelte:window onkeydown={onKeydown} />
<HorizonLayout id="my-layout" bind:config bind:maximizedTabId>
{#snippet maximizeButton({ action })}
<button onclick={action}>Maximize</button>
{/snippet}
{#snippet restoreButton({ action })}
<button onclick={action}>Restore</button>
{/snippet}
<LayoutItem id="editor" title="Editor"><!-- ... --></LayoutItem>
</HorizonLayout>
Any falsy value (null, undefined, '') clears the maximize state. maximizeButton and restoreButton must still be provided for maximize to be enabled.
Each HorizonLayout uses its id to scope drag events, so tabs from one layout cannot be dropped into another.
<HorizonLayout id="left-panel" bind:config={leftConfig}>
<LayoutItem id="editor" title="Editor"><!-- ... --></LayoutItem>
</HorizonLayout>
<HorizonLayout id="right-panel" bind:config={rightConfig}>
<LayoutItem id="preview" title="Preview"><!-- ... --></LayoutItem>
</HorizonLayout>
Each layout also persists independently to its own localStorage key.
persist defaults to true. Set it to false to opt out of localStorage entirely.
<HorizonLayout id="my-layout" bind:config persist={false}>
<LayoutItem id="main" title="Main"><!-- ... --></LayoutItem>
</HorizonLayout>
<HorizonLayout id="my-layout" bind:config dragAndDrop={false}>
<LayoutItem id="main" title="Main"><!-- ... --></LayoutItem>
</HorizonLayout>
bind:config gives you a reactive reference to the live layout config.
<script lang="ts">
let config = $state<LayoutConfig | null>(null);
$effect(() => {
if (config) {
console.log('Layout changed:', JSON.stringify(config, null, 2));
}
});
</script>
<HorizonLayout id="my-layout" bind:config><!-- ... --></HorizonLayout>
By default (persist={true}), the layout config and current maximize state are written to localStorage under the layout's id key after every change and restored on mount.
Set persist={false} to disable this entirely. The id prop is still required even when persistence is off — it is also used to isolate drag-and-drop between multiple layout instances on the same page.
Import the default stylesheet:
import 'horizon-layout/horizon-layout.css';
Or copy and override it. The library uses class-based styling with no theme object.
| Class | Element |
|---|---|
.horizon-layout |
Root container |
.horizon-layout__empty |
Empty layout state |
.horizon-layout-split |
Split container |
.horizon-layout-split__pane |
Individual pane inside a split |
.horizon-layout-split__resizer |
Drag handle between panes |
.horizon-layout-panel |
Panel container |
.horizon-layout-panel__tabs |
Tab strip |
.horizon-layout-panel__tab |
Individual tab |
.horizon-layout-panel__tab--active |
Active tab |
.horizon-layout-panel__content |
Tab content area |
.horizon-layout-panel__overlay |
Drag-over overlay |
| Property | Purpose |
|---|---|
--horizon-layout-bg |
Layout background |
--horizon-layout-panel-bg |
Panel background |
--horizon-layout-border |
Border color |
--horizon-layout-text |
Primary text |
--horizon-layout-muted |
Muted text |
--horizon-layout-accent |
Accent / active color |
--horizon-layout-shadow |
Shadow |
--horizon-layout-radius-panel |
Panel corner radius |
--horizon-layout-radius-tab |
Tab corner radius |
--horizon-layout-gap |
Gap between panels |
.my-app {
--horizon-layout-bg: #0f1117;
--horizon-layout-panel-bg: #1a1d27;
--horizon-layout-border: #2e3245;
--horizon-layout-text: #e2e4f0;
--horizon-layout-muted: #6b7094;
--horizon-layout-accent: #7c6af7;
}
<HorizonLayout id="my-layout" bind:config class="my-app">
<!-- ... -->
</HorizonLayout>