Splitter / Resizable
ProHorizontal and vertical resizable panels with a drag handle, collapsible sides, a two-way splitAt model and optional layout persistence.
import { SvSplitterComponent } from '@styloviz-pro/layout';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Collapsible vertical split
ProPersisted split position
ProWhen to use
- Letting users resize two adjacent panels (editor/preview, list/detail, sidebar/content).
- You want a draggable, keyboard-resizable divider with min/max bounds.
- For fixed layout regions that never resize, plain CSS grid is enough.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Item identity & owned state
- Restorable state
- legacyPersistKeys
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Saved state
- Stored kinds
- sv-splitter/layout
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| orientation | SplitterOrientation | 'horizontal' | Split direction: horizontal or vertical. |
| splitAt | number (two-way) | 50 | Split position as a percentage of the first panel (0–100). Two-way bindable. |
| minSize | number | 10 | Minimum size (%) for the first panel. |
| maxSize | number | 90 | Maximum size (%) for the first panel. |
| variant | SplitterVariant | 'default' | Visual variant of the gutter. |
| collapsible | boolean | false | Allow collapsing a panel via the gutter. |
| gutterSize | number | 6 | Gutter (drag handle) thickness in pixels. |
| label | string | 'Resizable panels' | Accessible label for the splitter. |
| keyboardStep | number | 5 | Percentage the split moves per arrow-key press. |
| persistKey | string | '' | When set, the split position and collapsed state are persisted to `localStorage` under this key and restored on load, inside a versioned envelope — see SvSplitterLayoutSnapshot. |
| legacyPersistKeys | readonly string[] | [] | Keys this layout was persisted under by an earlier release of the host. A RENAMED `persistKey` IS A LOST LAYOUT unless the old key is named here. When nothing is stored under `persistKey`, each of these is tried in order — an unreadable one is reported through `restoreRefused` and skipped, not the end of the search; the first that restores is copied to `persistKey` and removed, so the migration runs once. Refused blobs are left where they are. |
| syncAcrossTabs | boolean | false | Adopt a layout another tab of the same origin saves under `persistKey` (the `storage` event). Off, other tabs' writes are ignored until the next load — the layout never changes under the user's pointer. A refused or cleared value is always ignored. |
| minSizePx | number | null | null | Minimum first-panel size in px, resolved against the measured container and combined with `minSize` — the stricter wins. Ignored while the container has no size (hidden, not yet laid out, SSR). |
| maxSizePx | number | null | null | Maximum first-panel size in px, combined with `maxSize`. |
| minEndSizePx | number | null | null | Minimum second-panel size in px, so the END panel stays usable too. When the container cannot fit both pixel minimums, the first panel's wins. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| resized | number | Emits the first panel's new size as a whole percentage (clamped to the min bounds) on every drag move and keyboard step. |
| restoreRefused | SvSplitterRestoreRefusal | A layout stored under `persistKey` (or a legacy key) could not be applied — corrupt, another component's, from a newer release, or unmigratable — so the splitter opened at its defaults. Carries a user-safe sentence. |
| layoutReset | void | `resetLayout()` ran: the split is back at its initial position and the stored layout is gone. |
| splitAtChange | number | Emits the new `splitAt` whenever it changes — the output half of `[(splitAt)]`. |
Customization & theming
- Horizontal or vertical split.
- Collapsible panels with min/max sizes.
- Two-way split position binding.
- Set `persistKey` to save the split position + collapsed state to `localStorage` and restore them on load. The stored blob is a versioned envelope stamped `sv-splitter/layout`, so a value written by another component — or by an older release whose shape no longer fits — is refused rather than applied, and the panel simply opens where it did before.
- Tunable arrow-key step via `keyboardStep`.
- Pixel constraints: `minSizePx` / `maxSizePx` bound the first panel and `minEndSizePx` the second, resolved against the measured container and intersected with the percentage `minSize`/`maxSize` — the stricter wins. When the container cannot fit both pixel minimums, the first panel's wins. The render is clamped without rewriting `splitAt`, so widening the window brings the user's own split back.
- Persistence policy: a stored layout that is corrupt, another component's, from a newer release or unmigratable is ignored — the splitter opens at its defaults, emits `restoreRefused` with a user-safe sentence, and does not overwrite that blob until the user changes the layout. Pre-envelope `{ at, collapsed }` blobs are still adopted. A renamed `persistKey` keeps its layout if the old key is listed in `legacyPersistKeys` (migrated once, then removed). `resetLayout()` restores the host's initial split, expands both panels, clears storage and emits `layoutReset`. Other tabs' writes are ignored unless `syncAcrossTabs` is on, in which case a valid one is adopted without being echoed back. Storage is only touched in the browser; on the server nothing is read or written.
- Privacy (X-6): with `persistKey` set, the only thing written to `localStorage` is a layout envelope — `{ kind: 'sv-splitter/layout', version: 1, state: { at, collapsed } }`, i.e. the split percentage and which panel (if either) is collapsed. It is layout-only: no panel content, no user identifiers and no PII, and nothing leaves the browser. Choose a `persistKey` that does not itself embed a user id or email. The storage glue (SSR guard, legacy keys tried in order, refusal reporting, cross-tab adoption, never echoing back what storage already holds) is the core `svPersistedStore` helper, reusable by any component that keeps a layout.
- Scroll roots, containing blocks and portals (X-9): nothing is portalled — the collapse buttons are absolutely positioned inside the gutter and overflow into the panels, which each clip their own content (`overflow-hidden`). There is no scroll root of its own: put scrolling content inside a panel. The host is `block h-full min-h-[200px]`, so a vertical split needs an ancestor with a definite height. A drag maps the pointer against the container's `getBoundingClientRect()`, so a `transform: scale()` ancestor is handled, but a rotated one is not supported. No built-in breakpoints: bind `orientation` (and the pixel bounds) to your own media query to stack panels on a phone.
- Measurement (X-4): the container is observed automatically (`svElementSize`), so pixel bounds re-resolve on resize and a container that is hidden or zero-sized at init gets real bounds the moment it is laid out — no manual call exists or is needed; until then only the percentage bounds apply.
- Variant axes (X-8): `variant` (default · minimal · thick) and `gutterSize` are appearance only; `orientation` is layout; `collapsible`, `keyboardStep`, `persistKey` / `legacyPersistKeys` / `syncAcrossTabs` and the pixel bounds are behavioural modes. Every appearance works with every mode. Limits: two panels only; collapse applies to one side at a time.
- Browser limits (X-11): `localStorage` is unavailable in some private modes and throws on quota — both are swallowed and the layout simply is not remembered. `inert` needs Chrome 102 / Safari 15.5 / Firefox 112; older engines keep the collapsed panel's content tabbable. Without `ResizeObserver` the pixel bounds resolve only at the last measured size.
Best practices
- Do: Set sensible `minSize`/`maxSize` so neither panel can collapse unusably.
- Do: Set `persistKey` so the user's split position and collapsed state survive reloads.
- Tip: Support keyboard resizing with arrow keys for accessibility (handled).
- Do: Add `minSizePx` / `minEndSizePx` wherever the splitter can reach a phone — a 10% minimum of 320px is a 32px dead panel.
- Do: Offer a "Reset layout" action wired to `resetLayout()`, and show `restoreRefused`'s `detail` when a saved layout could not be applied.
- Don't: Don't rename `persistKey` without listing the old one in `legacyPersistKeys` — every user's saved split is lost.
Accessibility
- Labelled `role="separator"` with aria-orientation and aria-valuenow/min/max.
- Keyboard resizable: Arrow keys nudge by `keyboardStep`, Home/End jump to min/max.
- Focus after collapse: focus stays on the control that collapsed the panel — the collapse button or the separator (Enter on a collapsible separator collapses and restores the first panel, per the ARIA window-splitter pattern; when a panel is already collapsed — either one — Enter restores that panel, so the layout never jumps to the other side). If focus was inside the panel being collapsed, it moves to the separator first; it never drops to `<body>`.
- A collapsed panel is `inert`, so Tab cannot walk into content that is 0px wide.
- `aria-valuemin`/`-max`/`-now` report the pixel-resolved range when `minSizePx`/`maxSizePx`/`minEndSizePx` are set, so assistive tech hears the range the keyboard can actually reach.
Feedback on Splitter / Resizable
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.