Skip to content

Resizable panels for Svelte, animated with Motion

A Svelte 5 adapter built on the motion-panels core by Valerii Strilets, and inspired by the original motion-panels site. Drag a panel by its edge or add a separator. The panels next to this text are the real thing.

Quick start
bun add svelte-motion-panels motion
Files 160px
  • lib
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
  • index.ts
Workspace.svelte
1 <script lang="ts">
2 let width = $state(288)
3 </script>
4
5 <Group>
6 <Panel bind:size={width}>
7 <Files />
8 </Panel>
9 <Separator />
10 <Panel pin>
11 <Editor />
12 </Panel>
13 </Group>
Output 104px
  • $ bun add svelte-motion-panels
  • installed svelte-motion-panels
  • done in 0.4s
Agent 232px
  • you

    Make the file tree collapsible.

  • agent

    Bound collapsed on the Files panel.

  • agent

    Enter on its separator toggles it now.

Install

svelte-motion-panels is the components: Group, Panel, Separator and Handle, written with runes for Svelte 5. It depends on motion-panels, the framework-free resizing engine, and takes motion as a peer, so you install that next to it.

shell
bun add svelte-motion-panels motion
ts
import { Group, Handle, Panel, Separator } from 'svelte-motion-panels'

// The framework-free core underneath, if you want it directly:
import { createPanel, createPanelGroup } from 'motion-panels'

A styled separator

Upstream ships a shadcn registry item for React. There is no Svelte registry, but the separator is one element with role='separator', so a styled one is a class away: a hairline on the seam and a small grip box, both pseudo-elements. Copy it into your project and it is yours to edit.

Files 240px
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
StyledDemo.svelte
<!--
A styled separator: a hairline on the seam and a small grip box in the middle,
both drawn with pseudo-elements on the Separator itself.
-->
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import { COMPACT, Card, Demo, Editor, FILES, Rows, px } from './shared';

	const compact = new MediaQuery(COMPACT, false);
	let width = $derived(compact.current ? 130 : 240);
</script>

<Demo>
	<Group orientation="horizontal">
		<Panel bind:size={width} minSize="20%" maxSize="55%">
			<Card label="Files" size={px(width)}>
				<Rows items={FILES} active="Panel.svelte" />
			</Card>
		</Panel>
		<Separator class="styled-separator" />
		<Panel>
			<Editor />
		</Panel>
	</Group>
</Demo>

<style>
	:global {
		.styled-separator {
			position: relative;
			z-index: 1;
			display: flex;
			align-items: center;
			justify-content: center;
			width: 12px;
			outline: none;
		}

		/* The hairline on the seam. */
		.styled-separator::after {
			position: absolute;
			inset-block: 0;
			width: 1px;
			background: var(--border);
			content: '';
			transition: background-color 150ms;
		}

		/* The grip box: six dots drawn with a background pattern. */
		.styled-separator::before {
			z-index: 1;
			box-sizing: border-box;
			width: 12px;
			height: 16px;
			border: 1px solid var(--border);
			border-radius: 2px;
			padding: 1px;
			background:
				radial-gradient(circle, var(--muted-foreground) 0.8px, transparent 1.1px) 0 0 / 4px 4px
					content-box,
				var(--card);
			content: '';
		}

		.styled-separator:is(:hover, [data-crossing])::after {
			background: var(--muted-foreground);
		}

		.styled-separator[data-resizing]::after {
			background: var(--foreground);
		}

		.styled-separator:focus-visible {
			box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 50%, transparent);
		}
	}
</style>

Quick start

A group is a flex container. A panel with a size holds it, a panel without one fills what is left. That is the whole layout. A size is pixels, or a percentage of the group that follows it as it resizes, and bind:size writes back whichever form it was given. No separator here: a sized panel is draggable by the edge facing the filling panel, so grab the seam below and pull.

Files 240px
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
HorizontalDemo.svelte
<script lang="ts">
	import { Group, Panel } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import { COMPACT, Card, Demo, Editor, FILES, PANE, Rows, px } from './shared';

	const compact = new MediaQuery(COMPACT, false);
	// A phone starts from the narrow size; a drag writes back through bind:size.
	let width = $derived(compact.current ? 130 : 240);
</script>

<Demo>
	<Group orientation="horizontal">
		<Panel bind:size={width} minSize="20%" maxSize="55%" class={PANE}>
			<Card label="Files" size={px(width)}>
				<Rows items={FILES} active="Panel.svelte" />
			</Card>
		</Panel>
		<Panel class={PANE}>
			<Editor />
		</Panel>
	</Group>
</Demo>

Separator

Drop a Separator between two panels and the same split gains a visible grip, keyboard control and double-click reset. It finds the sized panel next to it, resizes that one, and sits centred on the seam without taking space in the flow. It is a focusable [role='separator'] carrying the panel size on aria-valuenow.

Files 240px
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
KeyDoes
ArrowsGrow or shrink by 10px, along the group axis
Shift + arrowsThe same, by 50px
Page up / Page downThe same, by 50px, without a modifier
Home / EndJump to minSize or maxSize
EnterToggle collapsed (needs bind:collapsed or onCollapsedChange)
Double-clickReset to defaultSize, or to the size the panel mounted with
SeparatorDemo.svelte
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import { COMPACT, Card, Demo, Editor, FILES, PANE, Rows, SEPARATOR, px } from './shared';

	const compact = new MediaQuery(COMPACT, false);
	let width = $derived(compact.current ? 130 : 240);
</script>

<Demo>
	<Group orientation="horizontal">
		<Panel bind:size={width} minSize="20%" maxSize="55%" class={PANE}>
			<Card label="Files" size={px(width)}>
				<Rows items={FILES} active="Panel.svelte" />
			</Card>
		</Panel>
		<Separator class={SEPARATOR} aria-label="Resize files" />
		<Panel class={PANE}>
			<Editor />
		</Panel>
	</Group>
</Demo>

Orientation

The same split on the other axis. Nothing about the panel or the separator changes: the group decides the axis, the cursor, the separator orientation and which arrow keys grow it.

Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
Output 120px
  1. $ bun add svelte-motion-panels
  2. installed svelte-motion-panels
  3. done in 0.4s
VerticalDemo.svelte
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';

	import { Card, Demo, Editor, Lines, OUTPUT, PANE, SEPARATOR, px } from './shared';

	let height = $state(120);
</script>

<Demo>
	<Group orientation="vertical">
		<Panel class={PANE}>
			<Editor />
		</Panel>
		<Separator class={SEPARATOR} aria-label="Resize output" />
		<Panel bind:size={height} minSize={80} maxSize={220} class={PANE}>
			<Card label="Output" size={px(height)}>
				<Lines lines={OUTPUT} terminal />
			</Card>
		</Panel>
	</Group>
</Demo>

Collapsing and folds

A collapsed panel folds to zero, and the fold is yours to design: transition times the panel edge, and the content brings its own Svelte in: transition. Pick a preset and toggle. Binding collapsed also turns on drag below half the minimum and Enter on the separator: focus the grip and press Enter.

Navigator 260px
  • Group
  • Panel
  • Separator
  • createPanel
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
<Panel
  bind:size={width}
  bind:collapsed
  keepMounted={false}
>
  <div in:fold={'flip'}>
</Panel>

Svelte has no motion poses, so there is no initial or animate on a Panel. With keepMounted={false} the content unmounts once the fold closes and mounts again as it opens, which is when its in: transition plays. Leave keepMounted on (the default) and the content stays mounted, clipped at zero: reopening costs no mount, and nothing enters. The content is anchored to the edge facing the filling panel, so origin-right pins a scale or a flip to that edge.

FoldDemo.svelte
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import { FOLDS, FOLD_NAMES, fold, timingOf, type Fold } from './shared/folds.js';
	import {
		COMPACT,
		Card,
		Demo,
		Editor,
		PANE,
		Rows,
		SEPARATOR,
		SYMBOLS,
		px
	} from './shared';
	import Button from './ui/Button.svelte';
	import Toggle from './ui/Toggle.svelte';
	import ToggleGroup from './ui/ToggleGroup.svelte';

	const compact = new MediaQuery(COMPACT, false);
	let width = $derived(compact.current ? 140 : 260);
	// Bound, so a drag past half of minSize and Enter on the separator fold it too.
	let collapsed = $state(false);
	let preset = $state<Fold>('flip');
	// Off, so the content remounts on every unfold and its in: transition plays.
	let keepMounted = $state(false);

	const code = $derived(
		[
			'<Panel',
			'  bind:size={width}',
			'  bind:collapsed',
			`  keepMounted={${keepMounted}}`,
			...(timingOf(preset) ? [`  transition={${JSON.stringify(timingOf(preset))}}`] : []),
			'>',
			FOLDS[preset].code ? `  <div ${FOLDS[preset].code}>` : '  <div>',
			'</Panel>'
		]
			.join('\n')
			.replaceAll(/"(\w+)":/g, '$1: ')
			.replaceAll('"', "'")
	);
</script>

<Demo>
	{#snippet controls()}
		<ToggleGroup label="Fold preset" items={FOLD_NAMES} bind:value={preset} />
		<div class="ml-auto flex items-center gap-2">
			<Toggle bind:pressed={keepMounted}>keepMounted</Toggle>
			<Button size="sm" variant="outline" onclick={() => (collapsed = !collapsed)}>
				{collapsed ? 'Expand' : 'Collapse'}
			</Button>
		</div>
	{/snippet}

	<Group orientation="horizontal">
		<Panel
			bind:size={width}
			bind:collapsed
			{keepMounted}
			minSize="22%"
			maxSize="52%"
			transition={timingOf(preset)}
			class={PANE}
		>
			<div class="h-full origin-right" in:fold|global={preset}>
				<Card label="Navigator" size={px(width, collapsed)}>
					<Rows items={SYMBOLS} active="Panel" />
				</Card>
			</div>
		</Panel>
		<Separator class={SEPARATOR} aria-label="Resize navigator" />
		<Panel class={PANE}>
			<Editor />
		</Panel>
	</Group>

	{#snippet footer()}
		<pre
			class="m-0 overflow-x-auto px-3.5 py-3 font-mono text-[13px] text-muted-foreground">{code}</pre>
	{/snippet}
</Demo>
shared/folds.ts
import type { Transition } from 'motion';
import { reducedMotion } from 'motion-panels';
import { backOut, cubicOut, quadOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';

/**
 * Fold presets. There are no motion poses on a Svelte Panel: the panel edge
 * takes `transition`, and the content brings its own `in:` transition, which
 * plays as the panel unfolds when `keepMounted={false}` remounts it.
 */
export type Fold = 'fade' | 'scale' | 'flip' | 'snap' | 'spring';

interface Preset {
	/** How the preset reads in the demo footer. */
	code: string;
	enter: TransitionConfig | null;
	transition?: Transition;
}

export const FOLDS: Record<Fold, Preset> = {
	fade: {
		code: 'in:fade={{ duration: 350 }}',
		enter: { duration: 350, css: (t: number) => `opacity: ${t}` }
	},
	scale: {
		code: 'in:scale={{ start: 0.85 }}',
		enter: {
			duration: 250,
			easing: cubicOut,
			css: (t: number) => `transform: scale(${0.85 + 0.15 * t})`
		}
	},
	flip: {
		// No built-in flips a card in, so this one is folds.ts's own `fold` transition.
		code: "in:fold={'flip'}",
		enter: {
			duration: 280,
			easing: quadOut,
			css: (t: number) => `transform: perspective(500px) rotateY(${-75 * (1 - t)}deg)`
		}
	},
	snap: {
		code: '',
		enter: null,
		transition: { duration: 0 }
	},
	spring: {
		code: 'in:scale={{ start: 0.9, duration: 700, easing: backOut }}',
		enter: {
			duration: 700,
			easing: backOut,
			css: (t: number) => `transform: scale(${0.9 + 0.1 * t})`
		},
		transition: { bounce: 0.4, duration: 0.7, type: 'spring' }
	}
};

export const FOLD_NAMES = Object.keys(FOLDS) as Fold[];

/**
 * The content transition for a preset: `<div in:fold={name}>`. Instant under
 * prefers-reduced-motion, like the panel edge itself.
 */
export const fold = (_node: Element, name: Fold): TransitionConfig =>
	(!reducedMotion.get() && FOLDS[name].enter) || { duration: 0 };

/** The panel's own fold timing for a preset, if it sets one. */
export const timingOf = (name: Fold): Transition | undefined => FOLDS[name].transition;

Pinning

A filling panel reflows its content on every frame of a fold. A pinned one sizes the content once, up front, and anchors it to the edge that is not moving, so the content holds still while the fold slides the panel edge across it. Toggle the pin off and watch the paragraph rewrap the whole way through.

Sidebar 240px
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte

Pinning holds this text at the width the panel ends the fold with, so the line breaks are measured once instead of on every frame. Turn the pin off and watch the words rewrap the whole way through. Real content pays that cost on every frame too: a code editor relaying out, a virtualised table remeasuring its rows.

Pin content that bleeds to its own edges: an editor, a document, a table. A block with its own border or rounded corners shows that edge jumping instead, which is why the paragraph here has no frame of its own. The anchor follows the fold, so a sized panel placed after the filling one pins to the start edge instead.

PinDemo.svelte
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import { COMPACT, Card, Demo, FILES, PANE, Rows, SEPARATOR, px } from './shared';
	import Button from './ui/Button.svelte';
	import Toggle from './ui/Toggle.svelte';

	const PIN_TEXT =
		'Pinning holds this text at the width the panel ends the fold with, so the line breaks are measured once instead of on every frame. Turn the pin off and watch the words rewrap the whole way through. Real content pays that cost on every frame too: a code editor relaying out, a virtualised table remeasuring its rows.';

	const compact = new MediaQuery(COMPACT, false);
	let width = $derived(compact.current ? 130 : 240);
	let collapsed = $state(false);
	let pinned = $state(true);
</script>

<Demo>
	{#snippet controls()}
		<Toggle bind:pressed={pinned}>pin {pinned ? 'on' : 'off'}</Toggle>
		<Button size="sm" variant="outline" class="ml-auto" onclick={() => (collapsed = !collapsed)}>
			{collapsed ? 'Expand' : 'Collapse'}
		</Button>
	{/snippet}

	<Group orientation="horizontal">
		<Panel bind:size={width} bind:collapsed minSize="20%" maxSize="55%" class={PANE}>
			<Card label="Sidebar" size={px(width, collapsed)}>
				<Rows items={FILES} active="Group.svelte" />
			</Card>
		</Panel>
		<Separator class={SEPARATOR} aria-label="Resize sidebar" />
		<Panel pin={pinned}>
			<p class="overflow-hidden px-4 py-3 text-[13px] leading-[1.7] text-muted-foreground">
				{PIN_TEXT}
			</p>
		</Panel>
	</Group>
</Demo>

Nesting and intersections

Groups nest: here a vertical split lives inside the filling panel of a horizontal one. Where the two seams meet, press near the crossing and both separators follow the pointer. The cursor turns to move and each one resizes its own panel. Nothing to add: any separator whose grip reaches the pointer joins the drag.

Files 200px
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
Console 100px
  1. $ bun add svelte-motion-panels
  2. installed svelte-motion-panels
  3. done in 0.4s
NestedDemo.svelte
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import {
		COMPACT,
		Card,
		Demo,
		Editor,
		FILES,
		Lines,
		OUTPUT,
		PANE,
		Rows,
		SEPARATOR,
		px
	} from './shared';

	const compact = new MediaQuery(COMPACT, false);
	let sidebar = $derived(compact.current ? 120 : 200);
	let terminal = $state(100);
</script>

<Demo>
	<Group orientation="horizontal">
		<Panel bind:size={sidebar} minSize="18%" maxSize="45%" class={PANE}>
			<Card label="Files" size={px(sidebar)}>
				<Rows items={FILES} active="Panel.svelte" />
			</Card>
		</Panel>
		<Separator class={SEPARATOR} aria-label="Resize files" />
		<Panel>
			<Group orientation="vertical">
				<Panel class={PANE}>
					<Editor />
				</Panel>
				<Separator class={SEPARATOR} aria-label="Resize console" />
				<Panel bind:size={terminal} minSize={60} maxSize={180} class={PANE}>
					<Card label="Console" size={px(terminal)}>
						<Lines lines={OUTPUT} terminal />
					</Card>
				</Panel>
			</Group>
		</Panel>
	</Group>
</Demo>

Depth is not limited. Below, a horizontal split sits in the top panel of a vertical split, which sits in the filling panel of the outer row. Both crossings resize both axes: files with terminal at the left end of the terminal seam, outline with terminal at its right end. Two levels apart, and neither knows about the other.

Files 140px
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
Outline 120px
  • Group
  • Panel
  • Separator
  • createPanel
Terminal 90px
  1. $ bun add svelte-motion-panels
  2. installed svelte-motion-panels
  3. done in 0.4s
DeepNestDemo.svelte
<script lang="ts">
	import { Group, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import { fold, timingOf } from './shared/folds.js';
	import {
		COMPACT,
		Card,
		Demo,
		Editor,
		FILES,
		Lines,
		OUTPUT,
		PANE,
		Rows,
		SEPARATOR,
		SYMBOLS,
		px
	} from './shared';
	import Toggle from './ui/Toggle.svelte';

	const compact = new MediaQuery(COMPACT, false);
	let files = $derived(compact.current ? 92 : 140);
	let outline = $derived(compact.current ? 84 : 120);
	let terminal = $state(90);
	// Bound both ways: the toggles fold the panels, and so do drags and Enter.
	const hidden = $state({ files: false, outline: false, terminal: false });
</script>

<Demo tall>
	{#snippet controls()}
		<Toggle bind:pressed={hidden.files}>files (flip)</Toggle>
		<Toggle bind:pressed={hidden.outline}>outline (fade)</Toggle>
		<Toggle bind:pressed={hidden.terminal}>terminal (spring)</Toggle>
	{/snippet}

	<Group orientation="horizontal">
		<Panel
			bind:size={files}
			bind:collapsed={hidden.files}
			keepMounted={false}
			minSize="12%"
			maxSize="30%"
			class={PANE}
		>
			<div class="h-full origin-right" in:fold|global={'flip'}>
				<Card label="Files" size={px(files, hidden.files)}>
					<Rows items={FILES} active="Separator.svelte" />
				</Card>
			</div>
		</Panel>
		<Separator class={SEPARATOR} aria-label="Resize files" />
		<Panel>
			<Group orientation="vertical">
				<Panel>
					<Group orientation="horizontal">
						<Panel class={PANE}>
							<Editor />
						</Panel>
						<Separator class={SEPARATOR} aria-label="Resize outline" />
						<Panel
							bind:size={outline}
							bind:collapsed={hidden.outline}
							keepMounted={false}
							minSize="12%"
							maxSize="30%"
							class={PANE}
						>
							<div class="h-full" in:fold|global={'fade'}>
								<Card label="Outline" size={px(outline, hidden.outline)}>
									<Rows items={SYMBOLS} active="Separator" />
								</Card>
							</div>
						</Panel>
					</Group>
				</Panel>
				<Separator class={SEPARATOR} aria-label="Resize terminal" />
				<Panel
					bind:size={terminal}
					bind:collapsed={hidden.terminal}
					keepMounted={false}
					minSize={60}
					maxSize={160}
					transition={timingOf('spring')}
					class={PANE}
				>
					<div class="h-full origin-bottom" in:fold|global={'spring'}>
						<Card label="Terminal" size={px(terminal, hidden.terminal)}>
							<Lines lines={OUTPUT} terminal />
						</Card>
					</div>
				</Panel>
			</Group>
		</Panel>
	</Group>
</Demo>

Reordering

Grab the dots in a panel header and carry the panel across: the two sides trade places and travel there. Give the group the order it should read and an onOrderChange callback, give each movable panel its value, and put a Handle inside it. The order lives in your state, so it is yours to persist.

Files
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
Outline
  • Group
  • Panel
  • Separator
  • createPanel

order: [files, outline]

ReorderDemo.svelte
<script lang="ts">
	import { Group, Handle, Panel, Separator } from 'svelte-motion-panels';
	import { MediaQuery } from 'svelte/reactivity';

	import {
		COMPACT,
		Card,
		Demo,
		FILES,
		Lines,
		PANE,
		Rows,
		SEPARATOR,
		SOURCE,
		SYMBOLS
	} from './shared';

	const compact = new MediaQuery(COMPACT, false);
	let order = $state(['files', 'outline']);
	let files = $derived(compact.current ? 120 : 180);
	let outline = $derived(compact.current ? 96 : 130);

	// One keyed each over the whole row: a reorder swaps two keys, so Svelte
	// moves the two panel elements instead of remounting them.
	const row = $derived([order[0], 'seam-a', 'workspace', 'seam-b', order[1]]);
</script>

{#snippet grip()}
	<Handle
		class="flex flex-none cursor-grab items-center gap-[3px] rounded-sm px-1 py-1.5 outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 active:cursor-grabbing"
	>
		{#each [0, 1, 2] as dot (dot)}
			<span class="size-[3px] rounded-full bg-muted-foreground/60"></span>
		{/each}
	</Handle>
{/snippet}

<Demo>
	<Group {order} onOrderChange={(next) => (order = next)}>
		{#each row as item (item)}
			{#if item === 'workspace'}
				<Panel class={PANE}>
					<Card label="Workspace.svelte" head={grip}>
						<Lines lines={SOURCE} />
					</Card>
				</Panel>
			{:else if item.startsWith('seam')}
				<Separator class={SEPARATOR} />
			{:else if item === 'files'}
				<Panel value="files" bind:size={files} minSize={96} maxSize="40%" class={PANE}>
					<Card label="Files" head={grip}>
						<Rows items={FILES} active="Panel.svelte" />
					</Card>
				</Panel>
			{:else}
				<Panel value="outline" bind:size={outline} minSize={96} maxSize="40%" class={PANE}>
					<Card label="Outline" head={grip}>
						<Rows items={SYMBOLS} active="Separator" />
					</Card>
				</Panel>
			{/if}
		{/each}
	</Group>

	{#snippet footer()}
		<p class="px-3 py-2 font-mono text-[11px] text-muted-foreground">order: [{order.join(', ')}]</p>
	{/snippet}
</Demo>

Render the row with one keyed {#each}, sides and seams included. A reorder then swaps two keys, and Svelte moves the two existing panel elements instead of creating new ones, which is what the trip animates. The filling panel carries no value, and that is the whole rule: a group holds at most one sized panel on each side of it, so the panels that move are the ones you name in order. The middle card shows no dots, because Handle renders nothing inside a panel the group does not move. Handles are buttons: focus one and the arrow keys along the group axis move the panel too.

Panels on both edges

Each sized panel finds its own side: one before the filling panel drags on its end edge, one after it on its start edge. Two sized panels around one filling panel need no extra wiring, and again no separators. These two are percentages and stay percentages: a drag writes one back through bind:size, so they keep following the group.

Files 25%
  • +page.svelte
  • Group.svelte
  • Panel.svelte
  • Separator.svelte
Workspace.svelte
  1. 1 <script lang="ts">
  2. 2 let width = $state(240)
  3. 3 </script>
  4. 4
  5. 5 <Group orientation="horizontal">
  6. 6 <Panel bind:size={width}>
  7. 7 <Files />
  8. 8 </Panel>
  9. 9 <Separator />
  10. 10 <Panel pin>
  11. 11 <Editor />
  12. 12 </Panel>
  13. 13 </Group>
Outline 25%
  • Group
  • Panel
  • Separator
  • createPanel
BothEdgesDemo.svelte
<script lang="ts">
	import { Group, Panel } from 'svelte-motion-panels';

	import { Card, Demo, Editor, FILES, PANE, Rows, SYMBOLS } from './shared';

	// Percentages in, percentages out: a drag reports a percent string back.
	let left = $state<`${number}%`>('25%');
	let right = $state<`${number}%`>('25%');
</script>

<Demo>
	<Group orientation="horizontal">
		<Panel bind:size={left} minSize="14%" maxSize="35%" class={PANE}>
			<Card label="Files" size={left}>
				<Rows items={FILES} active="+page.svelte" />
			</Card>
		</Panel>
		<Panel class={PANE}>
			<Editor />
		</Panel>
		<Panel bind:size={right} minSize="14%" maxSize="35%" class={PANE}>
			<Card label="Outline" size={right}>
				<Rows items={SYMBOLS} />
			</Card>
		</Panel>
	</Group>
</Demo>

Under the hood

The components are a thin binding over the framework-free motion-panels core by Valerii Strilets. The core owns the numbers: bounds, drags, keyboard, folds, crossings, RTL and aria. It hands back motion values, and the adapter owns the DOM.

What every element needs

The core owns numbers, never nodes. It reads one attribute and hands back motion values and a state object; laying the flexbox out is the adapter's half of the deal. This is that half, in full.

ElementWhat you give it
group rootdisplay: flex, flex-direction from axes.direction, and overflow: clip on the outermost group.
filling panelThe FILL_ATTRIBUTE plus flex: 1 and a zero min-width or min-height. Every sized panel finds its own side by looking for this one.
sized panelflex-shrink: 0 and its extent from motion.size, floored at 0.
panel contentflex-shrink: 0, 100% on the cross axis, and its extent from motion.content: the value that holds a layout still while the panel edge slides across it.
separatorrole='separator', tabIndex, aria-orientation from axes.separator, touch-action: none, and attachSeparator. It writes aria-valuenow, data-resizing and data-crossing itself.
pinned fillA flex wrapper with justify-content from group.fill.anchor, and the child sized by group.fill.size. Both are motion values the folding panel drives.

The reorder drag is the one gesture written in this adapter, since vanilla motion has no drag gesture; everything else calls the motion-panels core.

Styling

Nothing ships styled. A separator is [role='separator'] with aria-orientation, centred on the seam it drags and taking no space in the flow, so give it a width and it straddles the boundary on its own. Its slot is zero wide, so give the grip position: relative and a z-index to paint it over both panels. It carries data-crossing while the pointer hovers a crossing it would drag from, and data-resizing from press to release. A sized panel with no Separator of its own renders one anyway as the drag area on its edge: that one carries data-motion-panels-edge and should stay invisible until it is focused. It is still a tab stop, so give it a focus-visible outline. Separator classes are global to the grip, so style them with a global stylesheet or :global.

css
[role='separator'] {
  position: relative; /* its slot is zero wide: lift the grip over both panels */
  z-index: 1;
}

[role='separator'][aria-orientation='vertical'] {
  width: 14px;
}

[role='separator']::after {
  border-radius: 999px;
  background: var(--border);
  content: '';
}

[role='separator']:hover::after,
[role='separator'][data-crossing]::after {
  background: var(--muted-foreground);
}

[role='separator'][data-resizing]::after {
  background: var(--primary);
}

[role='separator'][data-motion-panels-edge]::after {
  display: none;
}

[role='separator'][data-motion-panels-edge]:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: -2px;
}

API

Every component forwards the rest of its props to its element, class included. Content is a children snippet.

Group

Renders a div; class, style and every other attribute reach it. children is a snippet.

PropTypeDoes
orientation'horizontal' | 'vertical'Axis the panels split on, read once when the group mounts. Groups nest.
transitionTransitionTiming of the reorder trip: panels rendered in a new order travel there. Defaults to the house curve.
reorderboolean (default true)Pass false and reordered panels jump to their new place instead of travelling, with nothing measured on the way.
orderV[]The order the movable panels read in, one entry per panel that may move. A panel left out of it stays where it is, which is how the filling panel keeps its place.
onOrderChange(order: V[]) => voidThe new order, after a drag or an arrow key carried a panel past its neighbour. Assign it to your $state and render the panels in that order, with a keyed each so the elements move instead of remounting, and they travel there.

Panel

Generic in its size: a number reports numbers, a percent string reports percent strings. On a sized panel class and style reach the content box; on the filling panel they reach the panel itself.

PropTypeDoes
size (bindable)number | `${number}%`Current size, in pixels or as a percentage of the group extent. A percentage follows the group as it resizes. With bind:size a drag writes the new size back. A plain size={240} is uncontrolled: a drag moves the panel and it keeps that size (React would snap back). To control or veto a change, bind a getter and setter, bind:size={() => size, (next) => { if (ok(next)) size = next }}, or pair a getter binding with onSizeChange. Omit it and the panel fills what is left.
onSizeChange(size: S) => voidCalled with the new size as a drag or key press lands, in the form size was given. Optional next to bind:size; use it to react to a resize.
collapsed (bindable)booleanFolds the panel to zero. Bound or passed, even a plain collapsed={false}, it also turns on drag below half of minSize and Enter on the separator, which write the new value back. Like size, a plain value is uncontrolled; to veto a change, use a function binding, bind:collapsed={() => collapsed, (next) => { ... }}.
onCollapsedChange(collapsed: boolean) => voidCalled when a drag or Enter folds or unfolds the panel. Given on its own, it turns on drag-to-collapse and Enter too.
onFoldEnd() => voidThe panel finished travelling to its size: a fold, an unfold, or the settle after a drag. Sequence work on it instead of guessing at a duration.
defaultSizeSSize a double-click on the separator resets to. Defaults to the size the panel mounted with.
minSize / maxSizenumber | stringDrag and keyboard bounds, in pixels or as a percentage of the group extent. Both clamp to the room the other panels leave, and max defaults to all of it.
overshootboolean | number (default 22)A drag that reaches minSize or maxSize keeps stretching a little past it, then springs back on release. A number sets how far, in pixels; false or 0 stops the drag dead at the bound.
keepMountedboolean (default true)Keeps the content mounted once the panel has been open, clipped at zero while collapsed. Pass false to unmount it on every close: its in: transitions then play as the panel unfolds.
transitionTransitionTiming of the fold, a motion Transition. Defaults to the house curve.
valueunknownThis panel entry in the group order. Given one, a Handle inside the panel can carry it. Without one it never moves.
pinbooleanFilling panels only. Lays the content out once per fold instead of once per frame.
childrenSnippetThe panel content.

Handle

The grip that moves a panel. Renders a button, so anything inside it is yours, and aria-label defaults to the panel value: 'Move files' for a panel valued files, or 'Move panel' where the value is not a string or a number. Put one anywhere inside a panel that carries a value: pressing it starts the reorder drag, and the arrow keys along the group axis move the panel a place at a time. Inside a panel with no value, or a group with no order, it renders nothing at all. While a panel is carried, its group takes no pointer events.

Separator

No props of its own; the rest reach the grip div, and aria-label defaults to 'Resize panel'. Optional: rendered between two panels it resizes the sized one and sits over its edge without taking flow space. It keeps aria-valuenow, aria-valuetext and aria-valuemin current, adds aria-valuemax once the panel has a maxSize, and marks itself with data-resizing and data-crossing.

motion-panels

The framework-free core, installed as a dependency. The adapter re-exports none of its functions, only the Orientation and Size types; its own API, for plain DOM or another framework, is documented at https://motion-panels.letstri.dev.

PropTypeDoes
Orientation'horizontal' | 'vertical'Type of the Group orientation prop.
Sizenumber | `${number}%`Type of a panel size, in pixels or as a percentage.
data-motion-panels-fill / -separatorattributeSet on the filling panel and on a separator slot, from the core FILL_ATTRIBUTE and SEPARATOR_ATTRIBUTE. Target them from CSS.
Transitionfrom 'motion'What the transition props take. Omitted, they use the core house curve: 250ms on a custom ease, instant under prefers-reduced-motion.