Splitter / Resizable

Pro

Horizontal and vertical resizable panels with a drag handle, collapsible sides, a two-way splitAt model and optional layout persistence.

</>sv-splitter splitterresizablelayout
import { SvSplitterComponent } from '@styloviz-pro/layout';

Demo & usage

Basic usage

Pro

Usage example — open the gallery for the full interactive demo.

Pro

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Collapsible vertical split

Pro

Persisted split position

Pro

When 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 Change events 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 kind and schema version, 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

NameTypeDefaultDescription
orientationSplitterOrientation'horizontal'Split direction: horizontal or vertical.
splitAtnumber (two-way)50Split position as a percentage of the first panel (0–100). Two-way bindable.
minSizenumber10Minimum size (%) for the first panel.
maxSizenumber90Maximum size (%) for the first panel.
variantSplitterVariant'default'Visual variant of the gutter.
collapsiblebooleanfalseAllow collapsing a panel via the gutter.
gutterSizenumber6Gutter (drag handle) thickness in pixels.
labelstring'Resizable panels'Accessible label for the splitter.
keyboardStepnumber5Percentage the split moves per arrow-key press.
persistKeystring''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.
legacyPersistKeysreadonly 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.
syncAcrossTabsbooleanfalseAdopt 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.
minSizePxnumber | nullnullMinimum 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).
maxSizePxnumber | nullnullMaximum first-panel size in px, combined with `maxSize`.
minEndSizePxnumber | nullnullMinimum 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

EventPayload typeDescription
resizednumberEmits the first panel's new size as a whole percentage (clamped to the min bounds) on every drag move and keyboard step.
restoreRefusedSvSplitterRestoreRefusalA 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.
layoutResetvoid`resetLayout()` ran: the split is back at its initial position and the stored layout is gone.
splitAtChangenumberEmits 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.

Loads giscus.app and GitHub — nothing is fetched until you click.