Skip to content

Resizable — the cross-framework headless split pane

Resizable is a headless, accessible two-panel splitter / resizable pane with no third-party engine behind it. It covers the whole behaviour surface: pointer-drag resizing with pointer capture, role="separator" keyboard control, a [min, max] clamp, the two-way size percent, and the start / end / handle slots. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.

The foundation is the platform itself: native Pointer Events (with pointer capture so the drag keeps tracking even when the cursor leaves the handle) plus the keyboard. The component is fully controlled with no draft state: the first panel's percent is size (the sole model: true prop), and the second panel takes the remainder via CSS. There is no measured-geometry state to reconcile: the drag converts the pointer position within the container rect into a percent, clamps it to [min, max], and writes it straight back. Rozie owns the author-side API: the two-way r-model:size, the clamp / percent-from-pointer math (unit-tested once and shared by every leaf), and the token-themed skin.

Every visual value is a CSS custom property, so the splitter re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.

The @rozie-ui/resizable packages

Resizable ships as six pre-compiled, per-framework packages. Install the one for your framework; there is no build step and no Rozie toolchain to set up:

PackageInstallREADME
@rozie-ui/resizable-reactnpm i @rozie-ui/resizable-reactreact/README
@rozie-ui/resizable-vuenpm i @rozie-ui/resizable-vuevue/README
@rozie-ui/resizable-sveltenpm i @rozie-ui/resizable-sveltesvelte/README
@rozie-ui/resizable-angularnpm i @rozie-ui/resizable-angularangular/README
@rozie-ui/resizable-solidnpm i @rozie-ui/resizable-solidsolid/README
@rozie-ui/resizable-litnpm i @rozie-ui/resizable-litlit/README

Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).

Quick start

Two-way bind size and project the two panes into the start / end slots. Drag the handle (or focus it and use the Arrow keys) to resize; @resize fires on every committed change:

rozie
<components>
{
  Resizable: './Resizable.rozie',
}
</components>

<data>
{
  split: 30,
}
</data>

<template>
  <div style="height: 320px">
    <Resizable r-model:size="$data.split" :min="20" :max="80" direction="horizontal" @resize="onResize">
      <template #start><nav>Sidebar</nav></template>
      <template #end><main>Content</main></template>
    </Resizable>
  </div>
</template>

r-model:size is Rozie's two-way bind: the consumer hands Resizable a percent, Resizable writes the clamped new percent back on every drag / keyboard nudge, and the framework reconciler picks it up with no onChange → setState wiring. Because size is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so the splitter position is a form control ([formControl] / [(ngModel)] bind directly).

For the full prop / event / handle / slot reference, see the API page. For the per-framework consumption code, see the usage page.

Drag, no measured geometry

The deepest design choice in a split pane is where the state lives. @rozie-ui/resizable keeps a single number — size, the first panel's percent — and nothing else. There is no measured-pixel state to reconcile:

  • Pointer-drag uses native Pointer Events on the handle plus pointer capture (setPointerCapture), so pointermove / pointerup keep firing on the handle even when the cursor races past it — no document-level listeners, no global mousemove cleanup. (Rozie authors this with template @pointerdown / @pointermove / @pointerup, the typed-$event path.)
  • Each pointermove reads the container's getBoundingClientRect(), converts the pointer coordinate into a first-panel percent, and clamps it to [min, max]. The math (clampPercent / percentFromPointer) is a tiny pure module unit-tested once and vendored verbatim into all six leaves.
  • The panels are positioned purely by CSS off one custom property (--rozie-resizable-size): the first panel is width/height: var(--rozie-resizable-size), the second flexes into the remainder. No JS layout, no ResizeObserver.

Keyboard

Focus the handle (Tab), then drive it from the keyboard — the WAI-ARIA window-splitter pattern:

KeyAction
/ (horizontal) · / (vertical)Nudge size by 1% toward / away from the start panel.
HomeJump to min (smallest first panel).
EndJump to max (largest first panel).

The handle is a role="separator" with tabindex="0", aria-orientation (perpendicular to the split axis), and live aria-valuenow / aria-valuemin / aria-valuemax. When disabled, the handle sets aria-disabled and ignores both pointer and keyboard.

Theming

Every value the component renders is a --rozie-resizable-* CSS custom property with a built-in fallback, so it works with zero configuration yet is completely re-skinnable. Override tokens at any ancestor scope:

css
.rozie-resizable {
  --rozie-resizable-accent: #16a34a;
  --rozie-resizable-handle-size: 0.75rem;
  --rozie-resizable-grip-bg: rgba(0, 0, 0, 0.5);
}

Only cosmetic values flow through tokens; the structural rules (the flex container, the --size-driven first-panel basis, the remainder second panel, the vertical column variant) compile per-leaf and are not consumer-overridable.

The complete token table and the design-system bridges live on the dedicated theming page.

Accessibility

  • The handle is a role="separator" with tabindex="0" and aria-orientation set perpendicular to the split axis (vertical for a horizontal split, horizontal for a vertical split — matching the WAI-ARIA window-splitter contract).
  • Live aria-valuenow / aria-valuemin / aria-valuemax report the first panel's percent and bounds; aria-disabled reflects the disabled prop.
  • The drag uses pointer capture, so a resize started on the handle keeps tracking through the whole gesture regardless of where the pointer travels — no lost-pointer dead zones.
  • Reading the container rect happens only inside post-mount pointer handlers and the imperative handle (never eagerly), so the behavior is identical everywhere, including inside Lit's shadow root.

See also

Pre-1.0 — APIs may change between minor versions.