Resizable
A splitter: two or more panels laid out along one axis, with a draggable divider between each pair. It is the shape of every editor, every mail client and every dashboard with a sidebar — and the one component where getting the pointer maths slightly wrong is immediately obvious.
Sidebar
Drag the divider, or focus it and use the arrow keys.
Content
Double-click the divider to snap back to the default split.
Import
import { Resizable } from "@forte-ui/react";Three parts, and they nest in the order they appear on screen:
<Resizable.Group orientation="horizontal">
<Resizable.Panel defaultSize={30}>…</Resizable.Panel>
<Resizable.Handle />
<Resizable.Panel>…</Resizable.Panel>
</Resizable.Group>The group has no size of its own — it fills whatever box it is given, so give it one. A horizontal group with no height collapses to nothing, which looks like the component failing to render.
The layout is resolved on the client, from measurements the server does not have — but the server-rendered split is not a guess. The group reads the defaultSizes off its children and gives whatever is left to the panels that declared none, so the HTML arrives at the split the client is about to compute and there is nothing to correct on hydration. That shortcut needs the panels to be plain children of the group; if yours come out of a map through a wrapper component, declare a defaultSize on each one and the result is the same.
Examples
Orientation
orientation="vertical" stacks the panels and turns the dividers through ninety degrees, arrow keys included. It is the same component; nothing else changes.
Editor
The handle between stacked panels runs across, and moves with the up and down arrows.
$ pnpm build
✓ built in 412ms
Nesting
Groups nest. A vertical group inside a horizontal group's middle panel is the standard three-pane editor, and the two splits know nothing about each other — panels register with their nearest group, so there is no coordination to get wrong.
Pixel constraints
minSize="220px" is the constraint a sidebar actually has. A percentage cannot express it: 20% of an unknown container is not a promise about anything, and the same layout that looks right on a desktop puts the sidebar at 90px on a laptop. Percent and px can be mixed freely in one group, and a px constraint is re-checked whenever the group is resized, so it stays true as the window changes.
min 220px · max 60%
A floor in pixels is the constraint a sidebar actually has.
min 20%
Mixing units in one group is fine; everything resolves to percent.
onLayout → []
onLayout reports every panel's size, in percent and in DOM order, whenever the layout settles — including the first time.
Collapsing
A collapsible panel may be dragged past its minimum, into the gap between minSize and collapsedSize. It does not rest there: collapseThreshold puts a snap point partway across that gap, so releasing nearer the closed end shuts the panel and releasing nearer the open end springs it back to minSize.
Collapsed state is a controlled pair — collapsed and onCollapsedChange — rather than an imperative handle, so the button that toggles a sidebar is an ordinary button holding ordinary state, and the drag, the keyboard and your own UI all write to the same place.
inert as well as invisible, so the links above leave the tab order rather than becoming an unreachable trap.A panel collapsed to nothing is given inert. overflow: hidden hides pixels, not the accessibility tree — without it, Tab walks into a sidebar that is not on screen, which is the kind of trap that only shows up in an audit.
Collapsing to a rail
collapsedSize does not have to be zero. Leave a 56px rail behind and the collapsed panel keeps its icons — still visible, still focusable, so it is not made inert.
- Files
- Search
- Branches
Remembering the layout
autoSaveId writes the layout to localStorage and reads it back on the next mount. Sizes are stored positionally, so a saved layout is discarded if the number of panels has changed since it was written.
forte-resizable:forte-ui-docs-demo.Restoration happens after mount, because the server cannot read the store. Give the panels sensible defaultSizes as well, or the first paint is the default layout and the saved one arrives a frame later. Pass storage={sessionStorage} — or any object with getItem and setItem — to put it somewhere else.
Accessibility
Each handle is a real role="separator" with the window splitter keyboard interface, so the layout is reachable without a pointer at all. It reports the size of the panel before it through aria-valuenow, points at that panel with aria-controls, and carries aria-orientation describing the LINE — a divider between side-by-side panels is itself vertical.
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the next handle. |
| Arrow keys | Moves the handle by step (5% by default). Left/Right for a horizontal group, Up/Down for a vertical one, and the handle moves the way the key points in RTL too. |
| Page Up then Page Down | Moves the handle by largeStep (20% by default). |
| Home | Moves the handle as far toward the start as the constraints allow. |
| End | Moves the handle as far toward the end as the constraints allow. |
| Enter | Collapses or expands a collapsible neighbour. |
| Double-click | Resets the group to its default layout. |
The divider is a hairline, and a hairline is a 1px target. .forte-target widens the hit area to the 24px minimum (SC 2.5.8) without widening the line, so it can be grabbed with a thumb or a shaky hand while still looking like a divider rather than a gutter.
Under forced colors the line is repainted with CanvasText, its active state with Highlight, and a disabled handle with GrayText — the fills that carry those states everywhere else in the library are stripped in that mode, so the divider would otherwise vanish entirely.
Theming
Every property below is declared on Resizable.Group, so override them there — through its className or an inline style — not on an ancestor, where the group's own declaration would beat the inherited value. To re-skin a single handle, set the same property on that handle: an element's own declaration wins over the one it would otherwise inherit from the group.
| Property | Controls | Default |
|---|---|---|
--forte-resizable-handle-size | Thickness the divider occupies in the layout | 1px |
--forte-resizable-line-size | Thickness of the painted divider line at rest | 1px |
--forte-resizable-line-size-active | Thickness of the painted divider line while hovered, focused or dragged | 3px |
--forte-resizable-line-color | Colour of the divider line at rest | var(--forte-color-border) |
--forte-resizable-line-color-active | Colour of the divider line while hovered, focused or dragged | var(--forte-color-primary) |
--forte-resizable-grip-thickness | Thickness of the grip chip, across the divider | 6px |
--forte-resizable-grip-length | Length of the grip chip, along the divider | 22px |
--forte-resizable-grip-radius | Corner radius of the grip chip | var(--forte-radius-1) |
--forte-resizable-grip-bg | Fill of the grip chip | var(--forte-color-panel) |
--forte-resizable-grip-border | Border colour of the grip chip | var(--forte-color-border) |
--forte-resizable-grip-dot-color | Colour of the three dots inside the grip | var(--forte-color-foreground-subtle) |
--forte-resizable-grip-dot-size | Size of one grip dot | 2px |
--forte-resizable-grip-dot-gap | Space between grip dots | 2px |
--forte-resizable-duration | How long a panel takes to reach a new size. Applies to a collapse or an expand, never to a drag | var(--forte-duration-normal) |
--forte-resizable-ease | Curve a panel follows to a new size. Not a spring: overshooting a panel's size flashes a band of the panel behind it | var(--forte-ease-standard) |
--forte-resizable-overlay-z-index | Stacking level of the sheet mounted over the page during a drag. Above the library's own overlays (Drawer sits at 40) so a splitter inside one still drags | 50 |
Note the two thicknesses. --forte-resizable-handle-size is what the divider costs the LAYOUT; --forte-resizable-line-size is what you see. They are separate because a line that thickened on hover in flow would reflow both panels every time the pointer crossed it — and shift the very edge the pointer was aiming at out from under it.
The parts publish their state as data attributes — data-orientation, data-resizing and data-ready on the group, data-collapsed on a panel, data-dragging, data-disabled and data-collapsed on a handle — so a Tailwind arbitrary variant such as data-[dragging]:... targets them without a wrapper.
data-ready appears once the client has resolved the layout, and it is what gates the size transition. Before it, the panels are still at the split the server sent, and easing them to the real one would make every page load begin with a slide. During a drag the transition is off for the same reason in reverse — a panel that eases into place is a panel lagging the pointer.
API reference
Resizable.Group
| Prop | Type | Default | Description |
|---|---|---|---|
autoSaveId | string | Remember the layout under this key and restore it on the next visit. Sizes are stored positionally, so the saved layout is discarded if the number of panels has changed since. Restoration happens after mount — the server has no access to the store — so give the panels sensible `defaultSize`s as well, or the first paint is the default layout rather than the saved one. | |
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
largeStep | number | 20 | How far <kbd>Page Up</kbd> / <kbd>Page Down</kbd> moves a handle, in percent of the group. |
onLayout | ((sizes: number[]) => void) | Called with every panel's size, in percent and in DOM order, whenever the layout changes — including the first time it settles. | |
orientation | ResizableOrientation | horizontal | Which way the panels run. `"horizontal"` lays them out side by side with vertical handles between them; `"vertical"` stacks them. |
render | RenderProp<Record<string, unknown>> | Replaces the rendered `<div>` with another element or component. | |
step | number | 5 | How far one arrow-key press moves a handle, in percent of the group. |
storage | ResizableStorage | localStorage | Where `autoSaveId` writes. Defaults to `localStorage`; pass `sessionStorage`, or your own two-method object, to change that. |
Resizable.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
collapsed | boolean | Controls the collapsed state. Pass it with `onCollapsedChange` to drive a panel from your own button — a sidebar toggle needs no imperative handle, just this pair. | |
collapsedSize | ResizableLength | 0 | Size when collapsed. `0` hides the panel; a small `px` value leaves an icon rail behind. |
collapseThreshold | number | 0.5 | How far down the gap between `collapsedSize` and `minSize` the snap point sits, as a fraction. `0.5` is the midpoint: drag past halfway and the panel shuts, let go before it and it springs back to `minSize`. |
collapsible | boolean | false | Whether dragging past `minSize` snaps the panel shut instead of stopping. A collapsed panel publishes `data-collapsed`, and the handle beside it toggles it with <kbd>Enter</kbd>. |
defaultSize | ResizableLength | Size before anyone drags anything, as a percentage of the group or a `px` string. Panels without one split whatever the sized panels leave. | |
maxSize | ResizableLength | 100 | Largest the panel may be dragged to. |
minSize | ResizableLength | 0 | Smallest the panel may be dragged to. A `px` string is honoured at every container width — `minSize="240px"` is the constraint a sidebar actually has, and the one a percentage cannot express. |
onCollapsedChange | ((collapsed: boolean) => void) | Called when the panel collapses or expands, whichever caused it — a drag past the snap point, <kbd>Enter</kbd> on the handle, or your own state. | |
render | RenderProp<Record<string, unknown>> | Replaces the rendered `<div>` with another element or component. |
Resizable.Handle
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
disabled | boolean | false | Makes the divider inert: not focusable, not draggable, and drawn without a hover cue. |
grip | boolean | false | Draws a grip in the middle of the divider — the visible affordance for a control that is otherwise a hairline. |
resetOnDoubleClick | boolean | true | Whether a double-click resets the group to its default layout. On by default: it costs nothing, it is what every splitter does, and it is the only way back from a layout dragged into a corner without a reload. |