SplitPaneexperimental
Resizable two-pane layout with a draggable divider.
Playground
<SplitPane>
{#snippet start()}
<div class="bg-surface-elevated flex h-full items-center justify-center p-4">
<span class="text-text-secondary text-sm font-medium">Start pane</span>
</div>
{/snippet}
{#snippet end()}
<div class="flex h-full items-center justify-center p-4">
<span class="text-text-secondary text-sm font-medium">End pane</span>
</div>
{/snippet}
</SplitPane>01 Examples
SplitPane fills its container, so give that container a height. The first pane's share is a 0 to
1 number: start it with defaultRatio, or take control
with bind:ratio to read and persist the layout (to localStorage, say), while defaultRatio stays the double-click reset target. min and max bound
the pane as a px number or a percentage string, and onRatioChange fires after each drag or keyboard resize.
IDE layout: file tree and editor
bind:ratio next to the editor. Drag the divider or focus it and use the arrow keys. Double-click resets to defaultRatio.// main.ts
import App from './App.svelte';
import './styles.css';
export default new App(options);
<div class="border-border-subtle h-80 w-full overflow-hidden rounded-xl border">
<SplitPane bind:ratio={ideRatio} defaultRatio={0.28} min="18%" max="55%">
{#snippet start()}
<nav aria-label="Demo sidebar" class="bg-surface-elevated h-full overflow-auto p-3">
<p class="text-text-tertiary mb-2 px-1 text-xs font-semibold tracking-wide uppercase">
Explorer
</p>
<ul class="space-y-0.5 text-sm">
<li class="text-text-secondary flex items-center gap-2 px-1 py-1">
<FolderOpenIcon class="size-4" /> src
</li>
<li class="text-text-secondary flex items-center gap-2 px-1 py-1 pl-5">
<FileIcon class="size-4" /> App.svelte
</li>
<li
class="text-text-primary bg-primary-subtle flex items-center gap-2 rounded px-1 py-1 pl-5 font-medium"
>
<FileIcon class="size-4" /> main.ts
</li>
<li class="text-text-secondary flex items-center gap-2 px-1 py-1 pl-5">
<FileIcon class="size-4" /> styles.css
</li>
<li class="text-text-secondary flex items-center gap-2 px-1 py-1">
<FileIcon class="size-4" /> README.md
</li>
</ul>
</nav>
{/snippet}
{#snippet end()}
<main class="h-full overflow-auto p-4 font-mono text-sm">
<p class="text-text-tertiary">// main.ts</p>
<p class="text-text-secondary">import App from './App.svelte';</p>
<p class="text-text-secondary">import './styles.css';</p>
<p class="text-text-secondary mt-2">export default new App(options);</p>
</main>
{/snippet}
</SplitPane>
</div>Vertical split: output over log
Preview
Rendered output goes here. Resize the divider below to give the log more room when you need to read a long stack trace.
✓ build succeeded in 412ms
→ 24 modules transformed
→ dist/index.js 12.4 kB
! 1 unused export in utils.ts
→ watching for changes…
<div class="border-border-subtle h-96 w-full overflow-hidden rounded-xl border">
<SplitPane orientation="vertical" defaultRatio={0.55} min="20%" max="85%">
{#snippet start()}
<section aria-label="Preview" class="bg-surface-elevated h-full overflow-auto p-4">
<p class="text-text-primary mb-1 text-sm font-semibold">Preview</p>
<p class="text-text-secondary text-sm leading-relaxed">
Rendered output goes here. Resize the divider below to give the log more room when
you need to read a long stack trace.
</p>
</section>
{/snippet}
{#snippet end()}
<section aria-label="Build log" class="h-full overflow-auto p-4 font-mono text-xs">
<p class="text-success">✓ build succeeded in 412ms</p>
<p class="text-text-tertiary">→ 24 modules transformed</p>
<p class="text-text-tertiary">→ dist/index.js 12.4 kB</p>
<p class="text-text-secondary">! 1 unused export in utils.ts</p>
<p class="text-text-tertiary">→ watching for changes…</p>
</section>
{/snippet}
</SplitPane>
</div>Collapsible sidebar with onCollapsedChange
Main content. Collapse the sidebar to reclaim its width. Re-expand it by pressing Enter on the divider, and the previous ratio is restored.
<div class="w-full space-y-3">
<div class="flex items-center gap-3">
<Badge intent={sidebarCollapsed ? 'neutral' : 'success'} variant="soft" size="sm">
{sidebarCollapsed ? 'sidebar collapsed' : 'sidebar visible'}
</Badge>
<span class="text-text-tertiary text-xs"
>Drag the divider left, or focus it and press Enter.</span
>
</div>
<div class="border-border-subtle h-72 w-full overflow-hidden rounded-xl border">
<SplitPane
collapsible
defaultRatio={0.3}
min="15%"
max="60%"
onCollapsedChange={(c) => (sidebarCollapsed = c)}
>
{#snippet start()}
<nav aria-label="Demo sidebar" class="bg-surface-elevated h-full overflow-auto p-3">
<p class="text-text-primary mb-2 text-sm font-semibold">Filters</p>
<ul class="text-text-secondary space-y-1 text-sm">
<li>Status</li>
<li>Assignee</li>
<li>Priority</li>
<li>Label</li>
</ul>
</nav>
{/snippet}
{#snippet end()}
<main class="h-full overflow-auto p-4">
<p class="text-text-secondary text-sm leading-relaxed">
Main content. Collapse the sidebar to reclaim its width. Re-expand it by pressing
Enter on the divider, and the previous ratio is restored.
</p>
</main>
{/snippet}
</SplitPane>
</div>
</div>Chat beside a live artifact
Artifact preview
Starter
per month
Pro
per month
Team
per month
<div class="border-border-subtle h-96 w-full overflow-hidden rounded-xl border">
<SplitPane defaultRatio={0.42} min="25%" max="65%">
{#snippet start()}
<div class="flex h-full flex-col">
<div class="flex-1 space-y-3 overflow-auto p-4">
<div class="bg-surface-elevated ml-auto max-w-[80%] rounded-2xl px-3 py-2 text-sm">
Build me a pricing table.
</div>
<div class="text-text-secondary flex max-w-[85%] items-start gap-2 text-sm">
<SparklesIcon class="text-primary mt-0.5 size-4 shrink-0" />
<span>Done. The component is rendering in the panel on the right.</span>
</div>
</div>
<div class="border-border-subtle border-t p-3">
<div
class="border-border-default text-text-tertiary rounded-xl border px-3 py-2 text-sm"
>
Ask a follow-up…
</div>
</div>
</div>
{/snippet}
{#snippet end()}
<section aria-label="Preview" class="bg-surface-elevated h-full overflow-auto p-6">
<p class="text-text-primary mb-4 text-sm font-semibold">Artifact preview</p>
<div class="border-border-subtle grid grid-cols-3 gap-3">
{#each ['Starter', 'Pro', 'Team'] as tier (tier)}
<div class="border-border-subtle rounded-xl border p-3 text-center">
<p class="text-text-primary text-sm font-semibold">{tier}</p>
<p class="text-text-tertiary text-xs">per month</p>
</div>
{/each}
</div>
</section>
{/snippet}
</SplitPane>
</div>For a permanent navigation rail rather than two rebalanceable content regions, reach for SidebarLayout or Sidebar instead. SplitPane is for when both panes are primary content.
02 Accessibility
The "window splitter" pattern
The divider follows the WAI-ARIA window splitter pattern. It renders as role="separator" made focusable (tabindex="0"), carries aria-controls pointing at the first pane, and exposes its position as aria-valuenow / aria-valuemin / aria-valuemax (percentages, clamped into the configured min/max). Its aria-orientation reflects the divider bar itself, across the drag axis: vertical for a horizontal layout, horizontal for a vertical one. Give it a meaningful
name via handleLabel (default "Resize panes"). When disabled, the divider drops out of the tab order and
reports aria-disabled.
Keyboard
Tab moves focus to the divider. The arrow keys follow the layout axis:
- ← / → (horizontal) or ↑ / ↓ (vertical) resize by ±2%.
- Hold Shift with an arrow for a ±10% step.
- Home / End jump to the
min/maxlimit. - Enter toggles collapse when
collapsibleis set. Otherwise it resets todefaultRatio, the keyboard equivalent of the double-click reset.
Pointer & touch target
Drag uses setPointerCapture, so a resize keeps
tracking even if the pointer leaves the divider. The divider is a full-length hit area along
the split axis rather than a hairline, keeping it reachable for touch and coarse pointers.
The data-dragging and data-collapsed attributes on the root expose the live
state for CSS-only styling.
03 API Reference
21 propsProp | Type | Default | Description | |
|---|---|---|---|---|
end required | Snippet | — | Second pane — fills the space the first pane leaves. Required. | |
start required | Snippet | — | First pane. In horizontal orientation it is the leading (left) pane; in vertical the top pane. Required. | |
class | string | — | Extra classes merged onto the root container (the flex box). | |
collapseThreshold | number | 48 | Pixel width/height of the first pane below which a drag snaps it collapsed. Only used when collapsible. | |
collapsible | boolean | false | Allow the first pane to collapse to zero: dragging below
collapseThreshold, or pressing Enter on the divider, snaps it shut. | |
defaultRatio | number | 0.5 | Initial ratio for uncontrolled use; also the target of a double-click reset. | |
disabled | boolean | false | Disable resizing — the divider becomes inert (not focusable, no pointer/keyboard response). Panes stay visible. | |
handle | Snippet | — | Custom content rendered inside the divider (e.g. a grip icon). Replaces the default line; the divider stays the focusable separator. | |
handleLabel | string | 'Resize panes' | Accessible name for the divider (its aria-label). | |
max | SplitPaneLimit | '90%' | Upper bound for the first pane — px (number) or percentage string ('90%'). | |
min | SplitPaneLimit | '10%' | Lower bound for the first pane — px (number) or percentage string ('20%'). | |
mint | MintProp | 'none' | Micro-interaction preset applied to the divider (the role="separator"
handle). Only applies while not disabled. | |
onCollapsedChange | (collapsed: boolean) => void | — | Fires when the first pane collapses (true) or re-expands (false) via drag or Enter. | |
onRatioChange | (ratio: number) => void | — | Fires after a drag/keyboard interaction changes the ratio. Receives the new ratio (0–1). Does not fire for consumer-driven bind:ratio writes. | |
orientation | horizontalvertical | 'horizontal' | Layout axis. horizontal places panes side by side (vertical divider); vertical stacks them (horizontal divider). | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ SplitPane: {...} }}>.
Prefer this over class overrides when the requested look falls outside the
semantic intent palette — presets keep hover/active/dark-mode logic coherent
and make the custom look reusable across the project. | |
ratio | number | — | First pane's share of the container, 0–1. Supports bind:ratio. When omitted, starts at defaultRatio and is managed internally. | |
slotClasses | Partial<Record<SplitPaneSlots, string>> | — | Per-slot class overrides merged with tv() styles. Slots: root (the flex
container — what class also targets) | startPane | endPane | handle (the
draggable divider / separator). | |
unstyled | boolean | — | Remove all default tv() classes; combine with slotClasses to rebuild the look. | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children' | 'class') | |
...SplitPaneVariants variant | VariantProps | — | Styling variants from SplitPaneVariants |
04 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
SplitPaneProps | interface | props | 0 | — | |
MintProp | type | helper | 1 | — | |
SplitPaneLimit | type | helper | 2 | A min/max boundary for the first pane. A raw number is pixels; a string
ending in % (e.g. '20%') is a percentage of the container. A bare numeric
string ('200') is also treated as pixels. | |
SplitPaneSlots | type | variant | 0 | Slot names derived from the tv() config above — single source of truth for slotClasses. | |
SplitPaneVariants | type | variant | 0 | — | |
MintName | type | helper | 0 | A mint name: a built-in (autocompleted), 'none' to disable, or any
consumer-registered name. (string & {}) keeps the registry open — a
custom name still type-checks, it just isn't suggested. A typo therefore
also still compiles (it resolves like an unregistered custom name and
warns at runtime); the union buys completion and docs, not validation. | |
MintConfig | interface | helper | 0 | — | |
SlotNames | type | helper | 0 | Extracts the slot-name union from a slotted tv() config function — the
companion to VariantProps. The slot-mode overload returns
(props?) => { [K in keyof S]: SlotFn }, so keyof ReturnType<T> is exactly
the set of slot names a component declares in tv({ slots: … }).
Use it to type a component's slotClasses prop from the single source of
truth (its *.variants.ts) instead of hand-maintaining a parallel union
that silently drifts when a slot is added or renamed: | |
VariantProps | type | helper | 1 | — | |
BuiltinMintName | type | helper | 0 | Built-in mint names as a literal union, so the mint prop autocompletes
across every component — the single list the hand-curated playground knobs
and docs used to drift away from. |
05 Installation
Import
import { SplitPane } from '@urbicon-ui/blocks';
import type { SplitPaneProps, SplitPaneLimit, SplitPaneOrientation } from '@urbicon-ui/blocks';