Primitives Layout and display
Splitter
Two panes and a separator that resizes them, by pointer or keyboard. The handle is a focusable separator whose aria-valuenow is the first pane's size; the same number drives the grid through one custom property. Tier 2, for the length of a drag.
- Tier
- 2 · Lazy scoped controller
- Behavior JS
- 1683 B brotli · 1881 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - 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
On this page
Example
aria-valuenow = 30
Drag the line between the panes, or focus it and use the arrow keys.
<div data-slean="splitter" style="--slean-split: 30">
<nav id="splitter-files" data-slean-part="pane" aria-label="Files">…</nav>
<div
role="separator"
tabindex="0"
aria-label="Files"
aria-controls="splitter-files"
aria-orientation="vertical"
aria-valuenow="30"
aria-valuemin="15"
aria-valuemax="60"
data-slean-part="handle"
></div>
<section data-slean-part="pane" aria-label="Editor">…</section>
</div><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';
// Restore the stored size on the server and in the browser alike.
let { size = 30 }: { size?: number } = $props();
</script>
<div
data-slean="splitter"
style="--slean-split: {size}"
{@attach (node) =>
on(node, 'slean:resize', (event) => {
size = (event as CustomEvent<SplitterResizeDetail>).detail.value;
})}
>
<nav id="files" data-slean-part="pane" aria-label="Files">…</nav>
<div
role="separator"
tabindex="0"
aria-label="Files"
aria-controls="files"
aria-orientation="vertical"
aria-valuenow={size}
aria-valuemin="15"
aria-valuemax="60"
data-slean-part="handle"
></div>
<section data-slean-part="pane" aria-label="Editor">…</section>
</div>Why this implementation exists
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, 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
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="splitter"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/splitter/register';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. |
Runtime profile
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).
Accessibility contract
- The handle is a
separatorwith a value, bounds and an orientation, named after the pane it resizes and pointing at it witharia-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.
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 |
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
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 hydration
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.
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. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-split | 50 | grid-template-columns, grid-template-rows |
--slean-space-2 | 0.5rem | grid-template-columns, grid-template-rows |
--slean-border | var(--slean-neutral-6) | border, background |
--slean-radius-md | 0.625rem | 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-accent | oklch(54% 0.19 258) | 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 | 1.25rem | 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.
Compatibility notes
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.
Examples
Stacked panes
data-slean-orientation="vertical" stacks the panes. The separator's own aria-orientation is then horizontal, and ArrowUp and ArrowDown move
it. Give the root a height.
Query
Results
<!-- Stacked panes: give the root a height; the separator is horizontal. -->
<div data-slean="splitter" data-slean-orientation="vertical" style="--slean-split: 60; block-size: 20rem">
<section data-slean-part="pane">…</section>
<div role="separator" tabindex="0" aria-orientation="horizontal" aria-valuenow="60" …></div>
<section data-slean-part="pane">…</section>
</div>Testing
packages/primitives/tests/splitter.test.tsVitest: keyboard, RTL, stacked panes, collapse, a drag session and its listeners, 1000 rootsapps/playground/tests/primitives/layout.spec.tsPlaywright: the splitter and the context menu in Chrome, with axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/splitter/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/splitter/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/splitter/behavior.tsthe behavior definitionpackages/primitives/src/splitter/register.tsthe registration modulepackages/styles/css/splitter.cssthe optional stylesheet