Resizable
Panels that share a row or a column, split by a divider people can drag or move with the keyboard. Sizes can be limited and remembered between visits.
Orientations
Drag the divider, or focus it and use the arrow keys. Home and End jump to the first panel's limits.
Runs
- Weekly digest
- Invoice follow-up
- Lead scoring
Run details
Weekly digest
Finished in 42 seconds. Sent to 18 people in the Growth workspace.
Side by side: 40% · 60%
Prompt
Summarise the open invoices older than 30 days.
Response
Four invoices are overdue, totalling $12,480. Northwind Ltd has the oldest, at 47 days.
Stacked: 40% · 60%
Chat and canvas
The canvas opens beside the chat with a grip. Sizes you choose are saved per panel set. Below 720px the canvas moves into a sheet.
Which invoices are overdue?
Three customers are more than 30 days late. I opened the details in the canvas.
Canvas is open.
Overdue invoices
| Customer | Days | Amount |
|---|---|---|
| Northwind Ltd | 47 | $5,200 |
| Bloom & Co | 38 | $3,180 |
| Juniper Studio | 31 | $4,100 |
File explorer
A plain divider splits a file tree from the open file. The tree keeps at least 220px and the file at least 35%. Narrow screens show one pane at a time.
Soul files
Select a file
Open a file from the tree to read its saved contents. Files cannot be edited here.
Usage
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@tulipfarm/sprout/resizable";
<div className="h-64 rounded-lg border">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel defaultSize="28%" minSize="220px">
<FileTree />
</ResizablePanel>
<ResizableHandle aria-label="Resize file tree" />
<ResizablePanel minSize="35%">
<OpenFile />
</ResizablePanel>
</ResizablePanelGroup>
</div>The group fills its parent, so give the parent a height. orientation is "horizontal" (default), which places panels side by side, or "vertical", which stacks them. Panels and handles must be direct children of the group. Fragments are fine.
defaultSize, minSize, and maxSize take a percent such as "40%", or a length in px, rem, em, vw, or vh. A plain number is pixels. A string without a unit is a percent. Panels without a defaultSize share the space that is left. A px minimum is checked again when the group changes size.
Handle
ResizableHandle draws a 1px line with a 9px hit area (24px on touch screens). Add withHandle for a small grip on splits people resize often, like a chat and its canvas. Leave it off for quiet splits like a file tree. Always give the handle an aria-label that names what it resizes.
Saved layouts
import { useResizableLayout } from "@tulipfarm/sprout/resizable";
const layout = useResizableLayout({
id: "app:chat-canvas",
panelIds: open ? ["chat", "canvas"] : ["chat"],
onlySaveAfterUserInteractions: true,
});
<ResizablePanelGroup
defaultLayout={layout.defaultLayout}
onLayoutChanged={layout.onLayoutChanged}
>
<ResizablePanel id="chat" minSize="40%" defaultSize={open ? "55%" : "100%"}>
<Chat />
</ResizablePanel>
{open && (
<>
<ResizableHandle withHandle aria-label="Resize canvas" />
<ResizablePanel id="canvas" minSize="320px" defaultSize="45%">
<Canvas />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>A layout is panel sizes in percent, keyed by panel id. defaultLayout is used when it names every panel shown. Otherwise each panel's defaultSize applies. When panels are added or removed, the layout starts again from the defaults or the saved layout for that set of panels.
onLayoutChange runs on every change, including each pointer move.onLayoutChanged runs once a change settles, with { isUserInteraction }. useResizableLayout saves to localStorage by default, or to any storage with getItem and setItem. With onlySaveAfterUserInteractions, it only saves sizes a person chose.
Accessibility
The handle is a focusable role="separator" with aria-valuenow, aria-valuemin, and aria-valuemax for the panel before it, and aria-controls pointing at that panel. Arrow keys along the split move it 5%. Home and End move it to that panel's minimum and maximum. Right-to-left pages flip the horizontal keys and drag.
Line and grip colors change in 120ms and stop when reduced motion is requested. While dragging, the page keeps a resize cursor and text is not selected.
Theme tokens
The line uses --sprout-line, --sprout-border on hover, and --sprout-focus while focused or dragging. The grip uses --sprout-border and --sprout-border-strong.