Resizable panels
Panels split by a draggable hairline handle, for editors and multi-pane tools.
Halcyon Supply
Still no remittance for the Sep 25 run.
Let me pull up the payment run for you.
Supplier
- Open invoices
- 14
- W-9
- On file
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@oration/canon/components/resizable";import { StatusLabel } from "@oration/canon/components/status-dot";import { cn } from "@oration/canon/lib/utils";import { MailIcon, MessageSquareIcon, PhoneIcon } from "lucide-react";import * as React from "react";export function Hero() { const conversations = [ { id: "c1", name: "Halcyon Supply", preview: "Still no remittance for the Sep 25 run", icon: PhoneIcon, }, { id: "c2", name: "Northwind Freight", preview: "Invoice INV-20931 was short-paid", icon: MailIcon, }, { id: "c3", name: "Orchard Street Bakery", preview: "Can I change my bank account?", icon: MessageSquareIcon, }, ]; const [active, setActive] = React.useState("c1"); const current = conversations.find((c) => c.id === active); return ( <div className="h-80 w-full overflow-hidden rounded-xl bg-background shadow-border"> <ResizablePanelGroup orientation="horizontal"> <ResizablePanel id="demo-queue" defaultSize="30%" minSize="160px" maxSize="40%" > <div className="h-full border-r border-border bg-sidebar"> <p className="px-3 pt-3 pb-2 text-xs font-medium text-muted-foreground"> Queue </p> <ul className="flex flex-col gap-0.5 px-1.5"> {conversations.map((conversation) => ( <li key={conversation.id}> <button type="button" aria-pressed={ conversation.id === active } onClick={() => setActive(conversation.id) } className={cn( "flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left outline-none transition-colors duration-150 hover:bg-sidebar-accent focus-visible:ring-3 focus-visible:ring-ring/40", conversation.id === active && "bg-sidebar-accent", )} > <conversation.icon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" /> <span className="min-w-0"> <span className="block truncate text-13 font-medium text-foreground"> {conversation.name} </span> <span className="block truncate text-xs text-muted-foreground"> {conversation.preview} </span> </span> </button> </li> ))} </ul> </div> </ResizablePanel> <ResizableHandle className="bg-transparent" /> <ResizablePanel id="demo-conversation" defaultSize="45%" minSize="30%" > <div className="flex h-full min-w-0 flex-col"> <div className="flex h-11 items-center border-b border-border px-4"> <p className="truncate text-13 font-medium text-foreground"> {current?.name} </p> </div> <div className="flex flex-1 flex-col gap-2 p-4"> <p className="w-fit max-w-[85%] rounded-2xl rounded-bl-md bg-muted px-3 py-2 text-13 text-foreground"> {current?.preview}. </p> <p className="w-fit max-w-[85%] self-end rounded-2xl rounded-br-md bg-card px-3 py-2 text-13 text-foreground shadow-border"> Let me pull up the payment run for you. </p> </div> </div> </ResizablePanel> <ResizableHandle /> <ResizablePanel id="demo-context" defaultSize="25%" minSize="140px" maxSize="40%" > <div className="flex h-full flex-col gap-3 p-4"> <p className="text-xs font-medium text-muted-foreground"> Supplier </p> <dl className="flex flex-col gap-2 text-13"> <div> <dt className="text-xs text-muted-foreground"> Open invoices </dt> <dd className="text-foreground tabular-nums"> 14 </dd> </div> <div> <dt className="text-xs text-muted-foreground"> W-9 </dt> <dd> <StatusLabel tone="success" className="text-[13px]" > On file </StatusLabel> </dd> </div> </dl> </div> </ResizablePanel> </ResizablePanelGroup> </div> );}Usage#
Resizable panels split a tool into panes separated by a 1px hairline handle that people drag, or move with the arrow keys. The Contact Center workspace uses it for its queue, conversation and context panes; editors and test consoles are the other fit. It wraps react-resizable-panels 4, whose group fills its parent, so the parent must have a height. The mistake is decorating the split: the handle is a structural hairline, not a bar, and the panes are regions of one plane, not cards.
When to use
- For a full-bleed tool with two or three panes whose balance depends on the task: the Contact Center queue, conversation and context.
- For an editor beside its preview or test console, such as a prompt next to a test call.
- For a transcript above notes or logs, split vertically.
- When a side pane should fold away completely and come back, with
collapsible.
When not to use
- For a fixed side panel that opens and closes over the content. Use Sheet
- For the app's navigation rail. Use Sidebar
- For resizing data grid columns, which have their own handle. Use Data grid
- On phones. Collapse to one pane with a back affordance instead. Use Responsive
- For a scrolling region inside one pane. Use Scroll area
The Hairline-and-Lift Rule
--border hairline. Panes are regions of one surface, never separately lifted cards.Three panes, then collapse
Anatomy#
- Group.
ResizablePanelGroup: a flex row or column that fills its parent (height: 100%,width: 100%, set inline by the library). - Panel.
ResizablePanel: a pane with a default, minimum and maximum size in percent or pixels. - Handle.
ResizableHandle: a 1px Hairlinerole="separator"between two panels. - Grip. Optional with
withHandle: a 4 by 24px Hairline pill centered on the handle, rotated for vertical groups. - Hit area. An invisible band around the hairline: 4px from the component, widened by the library to at least 10px for a mouse and 20px for touch.
Examples#
With a grip
withHandle adds a 4 by 24px pill on the hairline, a hint that the split moves. Here a prompt sits beside its test console.
Prompt
You answer supplier calls for Cedarline. Confirm the supplier ID or invoice number before sharing any payment details.
Test call
Start a call to hear the prompt in action.
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@oration/canon/components/resizable";export function WithGrip() { return ( <div className="h-56 w-full overflow-hidden rounded-xl bg-background shadow-border"> <ResizablePanelGroup orientation="horizontal"> <ResizablePanel id="grip-prompt" defaultSize="55%" minSize="30%" > <div className="flex h-full flex-col gap-2 p-4"> <p className="text-xs font-medium text-muted-foreground"> Prompt </p> <p className="text-13 text-pretty text-foreground"> You answer supplier calls for Cedarline. Confirm the supplier ID or invoice number before sharing any payment details. </p> </div> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel id="grip-test" defaultSize="45%" minSize="25%"> <div className="flex h-full flex-col gap-2 bg-surface p-4"> <p className="text-xs font-medium text-muted-foreground"> Test call </p> <p className="text-13 text-muted-foreground"> Start a call to hear the prompt in action. </p> </div> </ResizablePanel> </ResizablePanelGroup> </div> );}Vertical
orientation="vertical" stacks the panes, a transcript above wrap-up notes. The grip rotates and Up and Down resize.
Agent Thanks for calling Cedarline. Can I have the invoice number?
Caller It's INV-20877, from Halcyon Supply.
Agent That was paid on the Sep 25 run and should land today.
Wrap-up notes
Confirmed payment date. No follow-up needed.
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@oration/canon/components/resizable";export function Vertical() { return ( <div className="h-72 w-full max-w-md overflow-hidden rounded-xl bg-background shadow-border"> <ResizablePanelGroup orientation="vertical"> <ResizablePanel id="vertical-transcript" defaultSize="65%" minSize="30%" > <div className="flex h-full flex-col gap-2 overflow-auto p-4 text-13"> <p> <span className="font-medium text-foreground"> Agent </span>{" "} <span className="text-muted-foreground"> Thanks for calling Cedarline. Can I have the invoice number? </span> </p> <p> <span className="font-medium text-foreground"> Caller </span>{" "} <span className="text-muted-foreground"> It's INV-20877, from Halcyon Supply. </span> </p> <p> <span className="font-medium text-foreground"> Agent </span>{" "} <span className="text-muted-foreground"> That was paid on the Sep 25 run and should land today. </span> </p> </div> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel id="vertical-notes" defaultSize="35%" minSize="20%" > <div className="flex h-full flex-col gap-1.5 bg-surface p-4"> <p className="text-xs font-medium text-muted-foreground"> Wrap-up notes </p> <p className="text-13 text-foreground"> Confirmed payment date. No follow-up needed. </p> </div> </ResizablePanel> </ResizablePanelGroup> </div> );}Collapsible panel
collapsible with collapsedSize lets the steps pane snap shut when dragged past its 20% minimum. Enter on the handle collapses or reopens the panel before it.
Steps
Greet
Verify supplier
Look up invoice
Read status
Look up invoice
Ask for the invoice number, then call the invoice lookup tool with the supplier ID.
Drag the handle left past 20%, or focus it and press Enter, to collapse the steps.
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@oration/canon/components/resizable";import * as React from "react";export function CollapsiblePanel() { const [collapsed, setCollapsed] = React.useState(false); return ( <div className="flex w-full flex-col gap-2"> <div className="h-56 w-full overflow-hidden rounded-xl bg-background shadow-border"> <ResizablePanelGroup orientation="horizontal"> <ResizablePanel id="collapse-steps" defaultSize="30%" minSize="20%" maxSize="45%" collapsible collapsedSize="0%" onResize={(size) => setCollapsed(size.asPercentage === 0) } > <div className="flex h-full flex-col gap-1 bg-surface p-3"> <p className="px-1 pb-1 text-xs font-medium text-muted-foreground"> Steps </p> {[ "Greet", "Verify supplier", "Look up invoice", "Read status", ].map((step) => ( <p key={step} className="truncate rounded-md px-1 py-1 text-13 text-foreground" > {step} </p> ))} </div> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel id="collapse-editor" defaultSize="70%" minSize="40%" > <div className="flex h-full flex-col gap-2 p-4"> <p className="text-xs font-medium text-muted-foreground"> Look up invoice </p> <p className="text-13 text-foreground"> Ask for the invoice number, then call the invoice lookup tool with the supplier ID. </p> </div> </ResizablePanel> </ResizablePanelGroup> </div> <p role="status" className="text-xs text-muted-foreground"> {collapsed ? "Steps collapsed. Drag the handle right, or focus it and press Enter, to reopen them." : "Drag the handle left past 20%, or focus it and press Enter, to collapse the steps."} </p> </div> );}States#
import { cn } from "@oration/canon/lib/utils";export function States() { const cells = [ { label: "Rest", className: "", grip: false }, { label: "With grip", className: "", grip: true }, { label: "Focus visible", className: "ring-1 ring-ring", grip: true }, ]; return ( <div className="grid w-full gap-4 sm:grid-cols-3"> {cells.map((cell) => ( <div key={cell.label} className="flex flex-col gap-2"> <span className="text-xs text-muted-foreground"> {cell.label} </span> <div className="flex h-24 overflow-hidden rounded-lg bg-background shadow-border"> <div className="flex-1" /> <div className={cn( "relative flex w-px items-center justify-center bg-border", cell.className, )} > {cell.grip ? ( <span className="z-10 flex h-6 w-1 rounded-lg bg-border" /> ) : null} </div> <div className="flex-1 bg-surface" /> </div> </div> ))} </div> );}| State | Treatment |
|---|---|
| Rest | A 1px hairline. The cursor turns to a resize cursor over the hit area. |
| Hover and dragging | The library sets data-separator to hover or active, but the handle draws no change; only the cursor shows it. |
| Focus visible | A 1px Focus Indigo ring around the handle. |
| Collapsed | A collapsible panel dragged past its minimum snaps to collapsedSize (0% by default) and reopens past the threshold. |
| Disabled | disabled on a handle, panel or group stops resizing; the separator gets aria-disabled and a not-allowed cursor. |
Behavior#
- Sizes accept numbers as pixels and strings as percentages or with units:
defaultSize="25%",minSize="240px",maxSize="36%". - The group needs a sized parent. It sets
height: 100%andwidth: 100%inline; inside a flex column, give itmin-h-0 flex-1as the workspace does. - Pointer drags resize immediately.
resizePreviewMode="separator"on the group shows a preview line and resizes on release instead. - Double-clicking a handle resets its panel to
defaultSize, unless the handle hasdisableDoubleClick. - Give panels an
idwhen you persist layouts.onLayoutChanged(layout, meta)fires once after a drag or key press ends, withmeta.isUserInteraction, anddefaultLayoutrestores it. ResizablePanelappliesclassNameto a nested div inside the panel, not to the panel element itself.- Imperative control (collapse, expand, resize) goes through
panelRefandgroupRef.
Do and don't#
Content#
- Panes don't need a visible label when their contents are obvious, but each should start with a heading or header row that names it: Queue, Inspector.
- If a pane can collapse, say how to reopen it the first time it's hidden.
Accessibility#
- Each handle is a focusable
role="separator"witharia-valuenow,aria-valuemin,aria-valuemax,aria-controlsand anaria-orientationperpendicular to the group. - The separator has no name. When a split matters to the task, label it with
aria-label(for example Resize queue). - The focus ring is 1px, thinner than the 3px focus ring elsewhere. It sits on a 1px line, so check it against both panes' backgrounds.
- Everything a drag does is also on the keyboard: arrows resize by 5%, Home and End jump to the limits and Enter collapses or expands a collapsible panel.
- Pane content should reflow at narrow widths; set
minSizeso no pane becomes unreadable.
| Keys | Action |
|---|---|
| ←→ | In a horizontal group, shrinks or grows the panel before the handle by 5%. |
| ↑↓ | In a vertical group, shrinks or grows the panel above the handle by 5%. |
| Home | Shrinks the panel before the handle to its minimum. |
| End | Grows the panel before the handle to its maximum. |
| Enter | Collapses or expands the panel before the handle, if it is collapsible. |
| F6 | Moves focus to the next handle in the group; Shift+F6 goes back. |
Design tokens#
| Token | Used for |
|---|---|
--border | The 1px handle and the grip |
--ring | The handle's 1px focus ring |
--radius-lg | The grip's rounded ends |
API reference#
ResizablePanelGroup
The group. Lays panels out and owns the layout.
Other props spread onto react-resizable-panels Group (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Side by side, or stacked. |
defaultLayout | { [panelId: string]: number } | No default | Starting sizes by panel id, in percent. Restores a saved layout. |
onLayoutChange | (layout: Layout) => void | No default | Called on every change, including each pointer move. |
onLayoutChanged | (layout: Layout, meta: LayoutChangedMeta) => void | No default | Called once a resize ends. Use it to persist the layout. |
resizePreviewMode | "panel" | "separator" | "panel" | Resize live, or show a preview line until release. |
resizeTargetMinimumSize | { coarse: number; fine: number } | { coarse: 20, fine: 10 } | Minimum hit target for handles and panel edges, in pixels. |
disabled | boolean | No default | Turns off resizing for the whole group. |
disableCursor | boolean | No default | Stops the library from setting resize cursors. |
groupRef | Ref<GroupImperativeHandle | null> | No default | getLayout() and setLayout(layout). |
ResizablePanel
One pane.
Other props spread onto react-resizable-panels Panel (<div>).
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | number | No default | Identifies the panel in layouts. Falls back to useId. |
defaultSize | number | string | No default | Starting size. Numbers are pixels, strings are percent unless they carry a unit. |
minSize | number | string | "0%" | Smallest size a drag or key press can reach. |
maxSize | number | string | "100%" | Largest size. |
collapsible | boolean | No default | Lets the panel snap to collapsedSize when dragged below minSize. |
collapsedSize | number | string | "0%" | Size when collapsed. |
collapsedThreshold | number | string | No default | How far past the minimum a drag must go to collapse or expand. |
groupResizeBehavior | "preserve-relative-size" | "preserve-pixel-size" | "preserve-relative-size" | What the panel keeps when the group itself resizes. |
onResize | (size: { asPercentage: number; inPixels: number }, id, prevSize) => void | No default | Called when this panel's size changes. |
panelRef | Ref<PanelImperativeHandle | null> | No default | collapse(), expand(), getSize(), isCollapsed() and resize(size). |
disabled | boolean | No default | Stops this panel from resizing, directly or indirectly. |
ResizableHandle
The draggable separator between two panels.
Other props spread onto react-resizable-panels Separator (<div role="separator">).
| Prop | Type | Default | Description |
|---|---|---|---|
withHandle | boolean | No default | Adds the centered grip pill. |
disabled | boolean | No default | Stops this handle from resizing its panels. |
disableDoubleClick | boolean | No default | Turns off reset-on-double-click. |
className | string | No default | Merged after the base classes. The workspace passes bg-transparent where the pane already draws its own border. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
The handle has no hover or active style. DESIGN.md's grid column handle shows a 1px indigo rule on hover; here only the cursor changes, although the library exposes data-separator="hover" and "active" to style.
The focus ring is ring-1, where focus indicators elsewhere are 3px at 40 to 50%.
aria-[orientation=vertical]:flex-col on the group never matches: the group has no aria-orientation. The library sets flex-direction inline, so vertical groups still stack.
The separator has no accessible name by default.
useDefaultLayout and the ref hooks from react-resizable-panels aren't re-exported from packages/canon, so persisting a layout means importing the library directly.
Only the Contact Center workspace imports it today.