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.

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.

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

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

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.

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.

Keyboard interactions
KeyBehaviour
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.
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 dragged3px
--forte-resizable-line-colorColour of the divider line at restvar(--forte-color-border)
--forte-resizable-line-color-activeColour 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 colour of the grip chipvar(--forte-color-border)
--forte-resizable-grip-dot-colorColour 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 or an expand, never to a dragvar(--forte-duration-normal)
--forte-resizable-easeCurve a panel follows to a new size. Not a spring: overshooting a panel's size flashes a band of the panel behind itvar(--forte-ease-standard)
--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 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

Props for ResizableGroup
PropTypeDefaultDescription
autoSaveIdstringRemember 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.
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.
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.
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`.
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>.
defaultSizeResizableLengthSize before anyone drags anything, as a percentage of the group or a `px` string. Panels without one split whatever the sized panels leave.
maxSizeResizableLength100Largest the panel may be dragged to.
minSizeResizableLength0Smallest 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.
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.