Resizable
Draggable two-pane split with a keyboard-accessible handle
The Resizable component splits a region into two panes with a divider the operator can drag. It exists for the layout backoffices reach for constantly — a record list beside a detail panel — where the right ratio depends on the screen, the dataset, and the person reviewing it.
Grid and Flex distribute space by a rule you decide at build time; Resizable hands that ratio to the user and reports it back through onSizeChange, so it can be persisted per operator. It is dependency-free: pointer drag plus a full keyboard model on a role="separator" handle.
Import
import { Resizable } from '@nim-ui/components';Playground
Drag the divider, or focus it and use the arrow keys. The root needs an explicit height.
<Resizable className="h-64" defaultSize={38} minSize={25} maxSize={70} handleLabel="Resize order list"> <div className="flex h-full flex-col gap-2 overflow-auto p-3 text-sm"> <div className="font-medium text-neutral-900 dark:text-neutral-100">Orders</div> <Dot status="pending">ord_9021</Dot> <Dot status="processing" pulse>ord_9020</Dot> <Dot status="success">ord_9019</Dot> <Dot status="failed">ord_9018</Dot> </div> <div className="flex h-full flex-col gap-2 overflow-auto p-4 text-sm"> <div className="text-base font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">ord_9020</div> <div className="text-neutral-600 dark:text-neutral-400">Label printing — carrier handoff pending.</div> <div className="tabular-nums text-neutral-600 dark:text-neutral-400">Total 1,284.00 THB</div> </div> </Resizable>
List beside an inspector
The motivating case. Give the first pane the list, the second the detail panel, and size the root — panes inherit the height.
- Customer
- Napat S.
- Total
- 1,284.00 THB
- Carrier
- Kerry
Code
<Resizable className="h-72" defaultSize={38} minSize={25} maxSize={65} handleLabel="Resize order list">
<ScrollArea className="h-full w-full">{/* order list */}</ScrollArea>
<RecordInspector aria-label="Order inspector">{/* detail panel */}</RecordInspector>
</Resizable>Vertical split
direction="vertical" stacks the panes and turns the handle into a horizontal bar — results above, a log console below.
Code
<Resizable direction="vertical" className="h-72" defaultSize={60} minSize={25} maxSize={80}>
<div className="h-full overflow-auto p-4">{/* results */}</div>
<div className="h-full overflow-auto p-4 font-mono text-xs">{/* log console */}</div>
</Resizable>Controlled size
Pass size and onSizeChange when the ratio belongs to your state — persisting it per operator, or syncing two splits. onSizeChange fires in uncontrolled mode too, so you can save the ratio without owning it.
function OrderWorkspace() {
const [size, setSize] = useState(() => loadPreference('orders.split') ?? 38);
return (
<Resizable
className="h-[calc(100vh-8rem)]"
size={size}
onSizeChange={(next) => {
setSize(next);
savePreference('orders.split', next);
}}
minSize={25}
maxSize={65}
handleLabel="Resize order list"
>
<OrderList />
<RecordInspector aria-label="Order inspector">{/* … */}</RecordInspector>
</Resizable>
);
}Locked split
disabled freezes the ratio while keeping the handle in the tab order, so the layout stays legible during read-only or loading states.
Code
<Resizable className="h-40" defaultSize={50} disabled>
<div className="h-full p-4">Locked list</div>
<div className="h-full p-4">Locked detail</div>
</Resizable>Props
| Name | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | Exactly two panes; extras are ignored and fewer render without a handle |
direction | 'horizontal' | 'vertical' | 'horizontal' | Split axis — horizontal places panes side by side with a vertical handle |
defaultSize | number | 50 | Uncontrolled starting size of the first pane, as a percentage |
size | number | - | Controlled size of the first pane, as a percentage |
onSizeChange | (size: number) => void | - | Called with the new first-pane percentage on every resize |
minSize | number | 15 | Smallest allowed first-pane percentage |
maxSize | number | 85 | Largest allowed first-pane percentage |
handleLabel | string | 'Resize panes' | Accessible name for the drag handle |
disabled | boolean | false | Lock the split — the handle stays focusable but ignores input |
className | string | - | Size the split here (h-*, max-h-*) — panes inherit it |
Accessibility
- The handle is a
role="separator"withtabIndex=0,aria-orientation, andaria-valuenow/aria-valuemin/aria-valuemaxreporting the first pane's percentage.aria-valuetextreads it as a percentage, andaria-controlspoints at the pane the handle sizes. minSize/maxSizeare percentages: they are clamped into 0–100 and normalized if passed reversed, so the reported range is always valid.- Keyboard: Arrow Left / Arrow Right (Arrow Up / Arrow Down when vertical) nudge by 1%, Shift + arrow by 10%, Home jumps to
minSize, End tomaxSize, and Enter resets todefaultSize. - Always set
handleLabelwhen a page has more than one split, so each separator has a distinct name. disabledsetsaria-disabledrather than removing the handle from the tab order — focus order stays stable when the lock lifts.- Focus shows the standard steel ring; the handle never relies on hover alone to signal that it is interactive.
Best Practices
Do
- Give the root an explicit height (
h-*ormax-h-*) — panes size themselves from it - Set
minSize/maxSizeso neither pane can be dragged into uselessness - Persist the controlled
sizeper operator; the ratio is a personal preference - Put a ScrollArea inside a pane when its content can overflow
Don't
- Nest Resizables to build a three-pane grid — reach for Grid or a dedicated layout
- Pass more than two children and expect a third pane; extras are ignored
- Use it for content that only needs a fixed ratio — Grid or Flex says that more clearly
- Rely on drag alone in a keyboard-first backoffice; the arrow-key model is the primary path
Related Components
- RecordInspector - The detail panel this layout usually holds
- ScrollArea - Bounded scrolling inside a pane
- Grid - Fixed-ratio layout when the user should not adjust it
- AdminShell - The outer backoffice frame