# Splitter

> Two panes with a draggable, keyboard-operable separator (WAI-ARIA window splitter).

Page: https://svelte-lean.vercel.app/primitives/splitter · Markdown: https://svelte-lean.vercel.app/primitives/splitter.md · Index: https://svelte-lean.vercel.app/llms.txt

Two panes with a handle between them that resizes them by pointer or keyboard, such as a sidebar beside an editor.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="splitter".
	import '@svelte-lean/styles/splitter.css';
	import type { SplitterResizeDetail } from '@svelte-lean/primitives/splitter';
	import { on } from 'svelte/events';

	let size = $state(30);
</script>

<div
	class="split"
	data-slean="splitter"
	style="--slean-split: {size}"
	{@attach (node) =>
		on(node, 'slean:resize', (event) => {
			size = (event as CustomEvent<SplitterResizeDetail>).detail.value;
		})}
>
	<nav id="splitter-files" class="pane" data-slean-part="pane" aria-label="Files">
		<ul>
			<li>src</li>
			<li>tests</li>
			<li>package.json</li>
			<li>README.md</li>
		</ul>
	</nav>
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		role="separator"
		tabindex="0"
		aria-label="Files"
		aria-controls="splitter-files"
		aria-orientation="vertical"
		aria-valuenow={size}
		aria-valuemin="15"
		aria-valuemax="60"
		data-slean-part="handle"
	></div>
	<section class="pane" data-slean-part="pane" aria-label="Editor">
		<p>The file list takes {size}% of the width.</p>
		<p>Drag the line between the panes, or focus it and use the arrow keys.</p>
	</section>
</div>

<style>
	.split {
		block-size: 14rem;
	}

	.pane {
		padding: var(--slean-space-4);
		font-size: var(--slean-text-sm);
	}

	.pane p {
		margin: 0 0 var(--slean-space-2);
	}

	.pane ul {
		display: grid;
		gap: var(--slean-space-2);
		margin: 0;
		padding: 0;
		list-style: none;
	}
</style>
```

Enter collapses the file list; Enter again restores it.

## Examples

### Stacked panes

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="splitter".
	import '@svelte-lean/styles/splitter.css';
</script>

<!-- Stacked panes: give the root a height; the separator itself is horizontal. -->
<div
	class="split"
	data-slean="splitter"
	data-slean-orientation="vertical"
	style="--slean-split: 60"
>
	<section id="splitter-query" class="pane" data-slean-part="pane" aria-label="Query">
		<p><code>SELECT name, plan FROM teams ORDER BY name;</code></p>
	</section>
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		role="separator"
		tabindex="0"
		aria-label="Query"
		aria-controls="splitter-query"
		aria-orientation="horizontal"
		aria-valuenow="60"
		aria-valuemin="20"
		aria-valuemax="80"
		data-slean-part="handle"
	></div>
	<section class="pane" data-slean-part="pane" aria-label="Results">
		<p>12 rows</p>
	</section>
</div>

<style>
	.split {
		block-size: 16rem;
	}

	.pane {
		padding: var(--slean-space-4);
		font-size: var(--slean-text-sm);
	}

	.pane p {
		margin: 0 0 var(--slean-space-2);
	}
</style>
```

ArrowUp and ArrowDown move a horizontal separator.

### Nested

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="splitter".
	import '@svelte-lean/styles/splitter.css';
</script>

<!-- A splitter inside a pane: a sidebar beside an editor stacked over a terminal. -->
<div class="split" data-slean="splitter" style="--slean-split: 25">
	<nav id="splitter-sidebar" class="pane" data-slean-part="pane" aria-label="Explorer">
		<p>Explorer</p>
	</nav>
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		role="separator"
		tabindex="0"
		aria-label="Explorer"
		aria-controls="splitter-sidebar"
		aria-orientation="vertical"
		aria-valuenow="25"
		aria-valuemin="10"
		aria-valuemax="50"
		data-slean-part="handle"
	></div>
	<div
		class="inner"
		data-slean-part="pane"
		data-slean="splitter"
		data-slean-orientation="vertical"
		style="--slean-split: 70"
	>
		<section id="splitter-editor" class="pane" data-slean-part="pane" aria-label="Code editor">
			<p>Editor</p>
		</section>
		<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
		<div
			role="separator"
			tabindex="0"
			aria-label="Code editor"
			aria-controls="splitter-editor"
			aria-orientation="horizontal"
			aria-valuenow="70"
			aria-valuemin="30"
			aria-valuemax="90"
			data-slean-part="handle"
		></div>
		<section class="pane" data-slean-part="pane" aria-label="Terminal">
			<p>Terminal</p>
		</section>
	</div>
</div>

<style>
	.split {
		block-size: 18rem;
	}

	/* The outer root draws the frame. */
	.inner {
		border: 0;
		border-radius: 0;
	}

	.pane {
		padding: var(--slean-space-4);
		font-size: var(--slean-text-sm);
	}

	.pane p {
		margin: 0 0 var(--slean-space-2);
	}
</style>
```

A splitter inside a pane: each handle resizes its own root.

### Stored size

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="splitter".
	import '@svelte-lean/styles/splitter.css';
	import type { SplitterResizeDetail } from '@svelte-lean/primitives/splitter';
	import { on } from 'svelte/events';

	const KEY = 'splitter-preview-size';
	let size = $state(50);

	// Restore after mount; a server-rendered page would read it from a cookie instead.
	$effect(() => {
		const stored = Number(localStorage.getItem(KEY));
		if (stored) size = stored;
	});
</script>

<div
	class="split"
	data-slean="splitter"
	data-slean-step="10"
	style="--slean-split: {size}"
	{@attach (node) =>
		on(node, 'slean:resize', (event) => {
			size = (event as CustomEvent<SplitterResizeDetail>).detail.value;
			localStorage.setItem(KEY, String(size));
		})}
>
	<section id="splitter-source" class="pane" data-slean-part="pane" aria-label="Markdown">
		<p>Markdown</p>
	</section>
	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
	<div
		role="separator"
		tabindex="0"
		aria-label="Markdown"
		aria-controls="splitter-source"
		aria-orientation="vertical"
		aria-valuenow={size}
		aria-valuemin="20"
		aria-valuemax="80"
		data-slean-part="handle"
	></div>
	<section class="pane" data-slean-part="pane" aria-label="Preview">
		<p>Preview</p>
	</section>
</div>

<style>
	.split {
		block-size: 10rem;
	}

	.pane {
		padding: var(--slean-space-4);
		font-size: var(--slean-text-sm);
	}

	.pane p {
		margin: 0 0 var(--slean-space-2);
	}
</style>
```

The size from slean:resize is stored and restored on the next visit; data-slean-step sets the arrow key step.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="splitter"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/splitter/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/splitter.css';
```

Render the root with `--slean-split`, two `pane` parts and the `handle` between them with `aria-valuenow`, `aria-valuemin`, `aria-valuemax`, `aria-controls` and a name, usually the first pane’s.

Listen to `slean:resize` to store the size, and render it back on the next visit through the same two attributes.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `<div data-slean="splitter" style="--slean-split: 30">` | – | yes | Options: data-slean-orientation (horizontal, vertical), data-slean-step (default 5). |
| pane | `any element` | `pane` | yes | Exactly two. The first is the one the value measures. |
| handle | `<div role="separator" tabindex="0" aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label>` | `handle` | yes | aria-orientation="vertical" for side-by-side panes, "horizontal" for stacked ones. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `ArrowLeft`/`ArrowRight` | focus on the handle, panes side by side | Smaller or larger first pane by the step; swapped under dir="rtl" |
| `ArrowUp`/`ArrowDown` | focus on the handle, panes stacked | Smaller or larger first pane by the step |
| `Home`/`End` | focus on the handle | Minimum or maximum size |
| `Enter` | focus on the handle | Collapses the first pane to its minimum; again restores it |

## Styling

`splitter.css` lays the root out as a grid whose first track is `--slean-split` percent, draws the handle as a line with a grip that appears on hover, focus and drag, and widens it for coarse pointers. `data-color` on the root or an ancestor colors the active handle (primary by default). The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-split` | `50` | grid-template-columns, grid-template-rows |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | grid-template-columns, grid-template-rows |
| `--slean-border` | `var(--slean-neutral-6)` | border, background |
| `--slean-radius-md` | `var(--slean-radius)` | border-radius |
| `--slean-surface` | `oklch(100% 0 0)` | background |
| `--slean-fg` | `var(--slean-neutral-12)` | color |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-radius-full` | `9999px` | border-radius |
| `--slean-border-strong` | `var(--slean-neutral-8)` | background |
| `--slean-color-main` | `var(--slean-primary-main)` | background |
| `--slean-focus-ring` | `var(--slean-focus-ring-width) solid var(--slean-focus-ring-color)` | outline |
| `--slean-focus-ring-width` | `2px` | outline-offset |
| `--slean-space-5` | `calc(var(--slean-spacing) * 5)` | grid-template-columns, grid-template-rows |

State selectors the stylesheet targets, all from the platform or ARIA: `:focus-visible`, `:hover`, `[aria-disabled="true"]`.

Variant attributes: `data-slean-orientation` (`vertical`); `data-slean-dragging`.

## Under the hood

Runtime and size

- **Tier**: 2 · Lazy scoped controller
- **Behavior JS**: 1908 B brotli · 2137 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `role="separator"`, `aria-valuenow`, `setPointerCapture()`, `touch-action`, `CSS grid`
- **Shared listeners**: keydown, pointerdown
- **Per-instance listeners**: scoped to one interaction session
- **Lazy state**: WeakMap entry created on first interaction
- **Native base**: `role="separator" (WAI-ARIA window splitter) + CSS grid`

The splitter registers one `keydown` and one `pointerdown` handler with the shared router. During a drag, and only then, the handle holds four listeners and the root's box. A thousand splitters keep one listener per type (`tests/splitter.test.ts`).

Why this implementation

CSS `resize` resizes one box by a corner grip, with no keyboard and nothing for assistive technology. The WAI-ARIA window splitter is the accessible form: a focusable `separator` with a value, moved by the arrow keys and by the pointer.

The value is the state. `aria-valuenow` on the handle and `--slean-split` on the root carry the same number, a percentage of the root, and the stylesheet turns the property into the first grid track, so a split rendered on the server is right before the script loads. The keyboard reads and writes the attribute and keeps nothing. A drag is the one moment that needs memory: pointer capture and four listeners on the handle, created on `pointerdown` and removed on release. That scoped lifetime is what Tier 2 means here.

### The browser owns

- focus on the handle and the keydown and pointerdown events routed to the behavior
- pointer capture: the handle keeps receiving the drag outside its own box
- the grid layout that turns one number into two panes

### Svelte Lean owns

- the WAI-ARIA window splitter keyboard: arrows by a step, Home, End, Enter to collapse and restore
- a pointer drag whose listeners live exactly as long as the drag
- aria-valuenow and --slean-split kept equal, bounded by aria-valuemin and aria-valuemax
- slean:resize after every change; RTL and stacked panes
- splitter.css: the grid, the handle line and grip, focus and drag states

Accessibility

- The handle is a `separator` with a value, bounds and an orientation, named after the pane it resizes and pointing at it with `aria-controls`.
- Arrow keys move it by `data-slean-step`; Home and End go to the bounds; Enter collapses and restores the first pane.
- Under `dir="rtl"` the first pane is on the right: the drag measures from the right edge and ArrowLeft enlarges it.
- A drag focuses the handle, so the keyboard continues from where the pointer left it.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Pointer events, pointer capture` | Widely available | Not applicable |
| `touch-action` | Widely available | Not applicable |

Without JavaScript

The panes render at the server's split and cannot be resized.

Server rendering and hydration

Render the same value in `aria-valuenow` and in the root's `--slean-split`. Nothing is measured on the server. A Content Security Policy without `style-src-attr 'unsafe-inline'` blocks the `style` attribute: the panes then start at an even split until the first key or drag, since the behavior writes through the CSSOM.

Before the behavior loads, the handle is focusable but neither keys nor drags move it. The first event after hydration works on the server's markup.

Compatibility

Pointer events, pointer capture, `touch-action` and CSS grid are widely available. More than two panes, pixel sizes and persistence are not part of the primitive; nest splitters and store the value from `slean:resize`.

Tests and source

- `packages/primitives/tests/splitter.test.ts` Vitest: keyboard, RTL, stacked panes, collapse, a drag session and its listeners, 1000 roots
- `apps/playground/tests/primitives/layout.spec.ts` Playwright: the splitter and the context menu in Chrome, with axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/splitter/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/splitter/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/splitter/behavior.ts` the behavior definition
- `packages/primitives/src/splitter/register.ts` the registration module
- `packages/styles/css/splitter.css` the optional stylesheet
