Resizable
Accessible resizable panel groups and layouts with keyboard support — horizontal or vertical, nestable, with an optional visible grip.
- Status
- Since
0.1.0- Accessibility pattern
- ARIA window splitter
Last updated
Install
Configure registry credentials first using the Quickstart. Before copying this item or any transitive registry dependency, verify the signed manifest and save the exact bytes for Resizable before installation. Retain the digest printed by this command.
pnpm exec vegastack-design verify resizableInstall with the pinned shadcn CLI. Bare shadcn does not verify VegaStack signature or integrity.
pnpm dlx shadcn@4.21.0 add @vegastack/resizableRun the exact offline --post-write command printed by the preflight, using its saved item path and independently retained --expected-integrity digest. Use the saved preflight for every transitive registry dependency too; stop on any mismatch before using the components.
It also adds the sanctioned engine to your package.json: react-resizable-panels (resizable layout engine).
Usage
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel>One</ResizablePanel>
<ResizableHandle />
<ResizablePanel>Two</ResizablePanel>
</ResizablePanelGroup>;A group fills its parent at 100%/100%, so the parent owns the height. Sizes are unitless strings
read as percentages — defaultSize="50%" and defaultSize="50" are the same thing, and a bare
number is pixels.
Anatomy
Examples
About
The Resizable component is built on top of
react-resizable-panels by
bvaughn. The engine owns the layout maths, the pointer drag and the
keyboard resize; the bar, the grip and the focus affordance are ours. We are on v4, whose API renamed
PanelGroup to Group, PanelResizeHandle to Separator and direction to orientation — the
three wrapper names on this page are unchanged by that.
Composition
A group is panels with handles between them. Every handle is a role="separator" control that
resizes the two panels it sits between.
ResizablePanelGroup
├── ResizablePanel
├── ResizableHandle
└── ResizablePanelVertical
orientation="vertical" stacks the panels and turns the handle into a horizontal bar. Groups nest,
so a vertical group inside a horizontal panel is how the two-axis layout in Usage is built.
Nesting the other way round — a horizontal group inside a vertical panel — puts the inner handle's end against the outer one, so the two boundaries meet in a T.
Handle
withHandle on ResizableHandle renders a visible grip centred on the bar. It is a visual
affordance only — the whole bar remains the drag target and the keyboard target either way.
RTL
Set dir="rtl" on the group. This component is not a Base UI part, so its reading direction comes
from the attribute rather than from DirectionProvider; the handle's own offsets are already
mirrored with rtl: variants.
Playground
Switch the split axis and the grip, then copy the generated JSX — including the bounded-height parent the group needs.
<div className="h-48">
<ResizablePanelGroup className="rounded-lg border">
<ResizablePanel defaultSize="40%" minSize="20%">One</ResizablePanel>
<ResizableHandle aria-label="Resize panels" />
<ResizablePanel minSize="20%">Two</ResizablePanel>
</ResizablePanelGroup>
</div>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
withHandle | boolean | — |
ResizablePanelGroup and ResizablePanel add no props of their own — each accepts everything the
corresponding react-resizable-panels v4 part accepts. On the group that is orientation,
disabled, defaultLayout, onLayoutChange / onLayoutChanged, groupRef and id; on a panel it
is defaultSize, minSize, maxSize, collapsible, collapsedSize, disabled, onResize,
panelRef and id. Both also take standard div attributes and a ref.
Accessibility
- Each handle is a
role="separator"witharia-orientation,aria-controlsnaming the panel it resizes, andaria-valuenow/aria-valuemin/aria-valuemaxcarrying that panel's current size — the ARIA window-splitter pattern. - A handle is a tab stop, and the arrow keys resize it; Home and End jump to the
bounds. A
disabledhandle drops out of the tab order and reportsaria-disabled. - Focus is the global 2px
:focus-visibleoutline frombase.css. Upstream suppressed it withfocus-visible:outline-hiddenand replaced it with a ring; both are removed (FOC-1, FOC-6), so a keyboard user can see which handle they are about to move. - The grip rendered by
withHandleis decoration; the accessible target is the bar itself, which is wider than its 1px line through anafter:hit area. - Give a handle an
aria-labelwhen a group has more than one, so the two separators are distinguishable when read out of context.
| Contract | States tested |
|---|---|
| Behaviour | default, hover, focus, dragging, disabled |
| Accessibility | native-or-base-ui-semantics, browser-accessibility-test, keyboard-navigation, disabled, focus-visible |
| Visual | default, focus, disabled |
Do / Don't
Deviations
Upstream's file plus packages/ui/upstream/patches/resizable.patch. Every hunk:
- FOC-1, FOC-6 —
focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-hiddengoes fromResizableHandle.outline-hiddenis the v3 spelling of "no outline", so upstream suppressed the one focus affordance this system has on a control that is keyboard-operable (arrow keys resize it).ring-offset-backgroundis left alone: it paints nothing without a ring width and is upstream's own class. - DOC-2 —
import { cn } from "cn"becomes@vegastack/design; our npm layer ownscn. - DOC-1 — prettier reflow plus the
registry-stamp.mjsprovenance header the three-copy model needs; no decision of its own.