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.
Anchored panels
A sidebar is not a share of the window. Declare its defaultSize in px and it is anchored: 260px of a wide window and 260px of a narrow one, and whatever the reader drags it to is remembered as a length — after the window is maximized, and on the next visit under autoSaveId. Only the percentage panels beside it grow and shrink, and they keep their ratio to each other while they do. The browser does the holding, through flex-basis, so the divider does not drift after the window edge; the group only steps in when a constraint bites, and then it corrects without easing, in the same frame.
The same goes for the size a panel shuts to. A 56px rail is stored as 56px whether it was a percentage panel or an anchored one, so a collapsed sidebar stays collapsed when the window grows instead of being read as "open, and below its minimum" at the new width.
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. The same point works in reverse — drag a shut panel past it and it springs open to minSize, short of it and it falls shut again — so a divider that snapped shut at 80px snaps open at 80px, and the two directions feel like one control.
Reopening it takes less. The divider beside a collapsed panel shows a one-way resize arrow pointing the way it will move, and a click — or a tap, or Enter — expands the panel to the size it shut at. A click is one gesture where a pull is two, and it puts the panel back where it was rather than at its minimum. Set expandOnClick={false} on the panel to turn the click off and leave the drag. The handle publishes data-expandable="start" or "end" — the side the shut panel is on — while a click would reopen it.
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.When the toggle should be the only route — a sidebar that must never vanish under a slipped drag — add collapseOnDrag={false}. The panel is still collapsible, so collapsed still works and data-collapsed still publishes, but the drag stops at minSize like any other panel's, Enter on the divider will not shut it, and while the panel is shut a drag leaves it shut — the same rule both ways. A click or Enter on the divider still reopens it, since nothing about a click can make a sidebar vanish; add expandOnClick={false} as well for a panel that only your own state may reopen.
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.The server cannot read the store, so the group's HTML carries a small inline script that applies the saved sizes while the page is still parsing — the first paint is the saved layout, not the default one corrected a frame later. Two things switch that off: a Content Security Policy that forbids inline scripts, and a custom storage. Pass storage={sessionStorage} — or any object with getItem and setItem — to put the layout somewhere else; in either case the page shows the defaults until the group hydrates, so give the panels sensible defaultSizes as well.
Either way, the saved layout is what the panels draw from their first render on the client, so content that measures its panel in a mount effect — a canvas fitting itself to its container, say — measures the restored split rather than the default one. That holds when the panels are direct children of the group. A panel inside a wrapper component of your own is restored a commit later, once the group has found it in the DOM, and a group that is hidden when it mounts settles once it has a size; for those, onReady fires once the layout has settled and the panels are drawn at it, and is the moment to measure.
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 | Behavior |
|---|---|
| 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. |
| Click | Expands a collapsed neighbour. The divider shows a one-way resize arrow, pointing the way it will move, while a click would do this. |
| 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 | 2px |
--forte-resizable-line-color | Color of the divider line at rest | var(--forte-color-border) |
--forte-resizable-line-color-active | Color 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 color of the grip chip | var(--forte-color-border) |
--forte-resizable-grip-dot-color | Color 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, an expand, or the snap at the end of a drag, never to the drag itself | var(--forte-duration-spring-precise) |
--forte-resizable-ease | Curve a panel follows to a new size. Keep it free of overshoot: a panel briefly larger than its target flashes a band of the panel behind it | var(--forte-ease-spring-precise) |
--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, data-snapping and data-ready on the group, data-collapsed on a panel, data-dragging, data-disabled, data-collapsed and data-expandable (start or end: the side of the collapsed neighbour a click would reopen) 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 — a saved layout included, which arrives in place rather than sliding in from the defaults. 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. The one exception is the snap: when a drag pushes a collapsible panel past its threshold the group raises data-snapping for as long as the resulting movement takes, so the panel eases shut (or open) along the same curve a button press uses instead of vanishing in a frame.
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, each in the unit its panel was declared in, so the saved layout is discarded if the number of panels has changed since. The saved layout is what the panels draw from their first render on the client — so content that measures its panel in a mount effect measures the restored split, not the default one — and with the default storage it is on screen before that: the group's server HTML carries a small inline script that reads `localStorage` while the page is still parsing. A page whose Content Security Policy forbids inline scripts, or a custom `storage`, shows the defaults until the group hydrates, so give the panels sensible `defaultSize`s as well. | |
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. | |
onReady | (() => void) | Called once, when the layout first settles: every panel has its size — restored under `autoSaveId`, or resolved from the `defaultSize`s — and is drawn at it. Content that measures its panel does not usually need this, because a saved layout is drawn from the first render; a group that mounts hidden, or whose panels are not direct `Resizable.Panel` children, settles later, and this is the moment to measure then. | |
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. |
collapseOnDrag | boolean | true | Whether the handle's drag may collapse the panel — dragging past `minSize`, or <kbd>Enter</kbd>, which is the same gesture from the keyboard. Set it to `false` for a sidebar that must never vanish under a slipped drag and collapses only through `collapsed`, from your own toggle: the drag then stops at `minSize` like any other panel's, <kbd>Enter</kbd> leaves an open panel open, and while the panel is shut a drag leaves it shut — the same rule both ways. A click still reopens it, and so does <kbd>Enter</kbd>, unless `expandOnClick` is off as well. Nothing without `collapsible`. |
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`. The same point works in reverse — drag a shut panel past it and it springs open to `minSize`, short of it and it falls shut again — so the two directions feel like one control. |
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> and reopens it on a click. |
defaultSize | ResizableLength | Size before anyone drags anything, as a percentage of the group or a `px` string — and the unit is kept. A percentage panel scales with the group; a px panel is anchored: it holds its length when the group changes size, is remembered as a length after a drag, and is stored as one under `autoSaveId`, while the percentage panels share whatever it leaves. Panels without one split whatever the sized panels leave. | |
expandOnClick | boolean | true | Whether a click — or a tap — on the handle reopens the panel once it is shut, with the handle showing a one-way arrow to say so; <kbd>Enter</kbd> reopens it wherever a click or a drag would. On by default: a drag reopens it too, where `collapseOnDrag` allows, but a click is one gesture where a pull is two, and it puts the panel back where it was rather than at `minSize`. Set it to `false` for a divider that reopens only by drag — or, with `collapseOnDrag` off as well, only through `collapsed`. Nothing without `collapsible`. |
maxSize | ResizableLength | 100 | Largest the panel may be dragged to. |
minSize | ResizableLength | 0 | Smallest the panel may be dragged to. A `px` string is honored 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> or a click 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. |