Skip to content

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.

resizable/basic.tsx

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.

resizable/vertical.tsx

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.

resizable/nested.tsx
Files
Editor
Terminal
Inspector

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.

resizable/constraints.tsx

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.

resizable/anchored.tsx
Then narrow the window: the sidebar keeps its width.
This pane is the one that grows and shrinks with the window.

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.

resizable/collapsible.tsx
Or drag the divider left, or press Enter on it — and once it is shut, click it.
A collapsed panel is 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.

resizable/toggle-only.tsx
Dragging stops at the minimum; only the button collapses it.
Drag the divider as far left as it goes: the sidebar holds at 160px instead of snapping shut.

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.

resizable/icon-rail.tsx
  • Files
  • Search
  • Branches
Drag the divider all the way left: the sidebar stops at the rail rather than disappearing. Click the divider to bring it back.

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.

resizable/persisted.tsx
Drag me, then reload.
Stored under 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.

Keyboard interactions
KeyBehavior
TabMoves focus to the next handle.
Arrow keysMoves 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 DownMoves the handle by largeStep (20% by default).
HomeMoves the handle as far toward the start as the constraints allow.
EndMoves the handle as far toward the end as the constraints allow.
EnterCollapses or expands a collapsible neighbour.
ClickExpands a collapsed neighbour. The divider shows a one-way resize arrow, pointing the way it will move, while a click would do this.
Double-clickResets 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.

Theming tokens for Resizable
PropertyControlsDefault
--forte-resizable-handle-sizeThickness the divider occupies in the layout1px
--forte-resizable-line-sizeThickness of the painted divider line at rest1px
--forte-resizable-line-size-activeThickness of the painted divider line while hovered, focused or dragged2px
--forte-resizable-line-colorColor of the divider line at restvar(--forte-color-border)
--forte-resizable-line-color-activeColor of the divider line while hovered, focused or draggedvar(--forte-color-primary)
--forte-resizable-grip-thicknessThickness of the grip chip, across the divider6px
--forte-resizable-grip-lengthLength of the grip chip, along the divider22px
--forte-resizable-grip-radiusCorner radius of the grip chipvar(--forte-radius-1)
--forte-resizable-grip-bgFill of the grip chipvar(--forte-color-panel)
--forte-resizable-grip-borderBorder color of the grip chipvar(--forte-color-border)
--forte-resizable-grip-dot-colorColor of the three dots inside the gripvar(--forte-color-foreground-subtle)
--forte-resizable-grip-dot-sizeSize of one grip dot2px
--forte-resizable-grip-dot-gapSpace between grip dots2px
--forte-resizable-durationHow 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 itselfvar(--forte-duration-spring-precise)
--forte-resizable-easeCurve 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 itvar(--forte-ease-spring-precise)
--forte-resizable-overlay-z-indexStacking 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 drags50

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

Props for ResizableGroup
PropTypeDefaultDescription
autoSaveIdstringRemember 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.
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
largeStepnumber20How 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.
orientationResizableOrientationhorizontalWhich way the panels run. `"horizontal"` lays them out side by side with vertical handles between them; `"vertical"` stacks them.
renderRenderProp<Record<string, unknown>>Replaces the rendered `<div>` with another element or component.
stepnumber5How far one arrow-key press moves a handle, in percent of the group.
storageResizableStoragelocalStorageWhere `autoSaveId` writes. Defaults to `localStorage`; pass `sessionStorage`, or your own two-method object, to change that.

Resizable.Panel

Props for ResizablePanel
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
collapsedbooleanControls 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.
collapsedSizeResizableLength0Size when collapsed. `0` hides the panel; a small `px` value leaves an icon rail behind.
collapseOnDragbooleantrueWhether 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`.
collapseThresholdnumber0.5How 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.
collapsiblebooleanfalseWhether 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.
defaultSizeResizableLengthSize 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.
expandOnClickbooleantrueWhether 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`.
maxSizeResizableLength100Largest the panel may be dragged to.
minSizeResizableLength0Smallest 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.
renderRenderProp<Record<string, unknown>>Replaces the rendered `<div>` with another element or component.

Resizable.Handle

Props for ResizableHandle
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
disabledbooleanfalseMakes the divider inert: not focusable, not draggable, and drawn without a hover cue.
gripbooleanfalseDraws a grip in the middle of the divider — the visible affordance for a control that is otherwise a hairline.
resetOnDoubleClickbooleantrueWhether 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.