Skip to content

Resizable panels

Panels split by a draggable hairline handle, for editors and multi-pane tools.

Status
Beta
Category
Layout
Adoption
Not used yet
import { ResizablePanelGroup } from "@oration/canon/components/resizable";
packages/canon/src/components/resizable.tsx

Queue

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

The handle is a structural divider: a 1px --border hairline. Panes are regions of one surface, never separately lifted cards.

Three panes, then collapse

Below 768px a multi-pane tool becomes one pane with a back affordance. The Contact Center workspace renders the group only on desktop and swaps panes on mobile.

Anatomy#

Prompt
Test call
  1. Group. ResizablePanelGroup: a flex row or column that fills its parent (height: 100%, width: 100%, set inline by the library).
  2. Panel. ResizablePanel: a pane with a default, minimum and maximum size in percent or pixels.
  3. Handle. ResizableHandle: a 1px Hairline role="separator" between two panels.
  4. Grip. Optional with withHandle: a 4 by 24px Hairline pill centered on the handle, rotated for vertical groups.
  5. 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#

Rest
With grip
Focus visible
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>    );}
States
StateTreatment
RestA 1px hairline. The cursor turns to a resize cursor over the hit area.
Hover and draggingThe library sets data-separator to hover or active, but the handle draws no change; only the cursor shows it.
Focus visibleA 1px Focus Indigo ring around the handle.
CollapsedA collapsible panel dragged past its minimum snaps to collapsedSize (0% by default) and reopens past the threshold.
Disableddisabled 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% and width: 100% inline; inside a flex column, give it min-h-0 flex-1 as 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 has disableDoubleClick.
  • Give panels an id when you persist layouts. onLayoutChanged(layout, meta) fires once after a drag or key press ends, with meta.isUserInteraction, and defaultLayout restores it.
  • ResizablePanel applies className to a nested div inside the panel, not to the panel element itself.
  • Imperative control (collapse, expand, resize) goes through panelRef and groupRef.

Do and don't#

Queue
Conversation
Do. Split panes with the plain 1px hairline, and let the panes sit on one surface.
Queue
Conversation
Don't. Gap the panes into separate cards and paint the handle as a thick indigo bar. It reads as three widgets, and indigo is not a divider color.

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" with aria-valuenow, aria-valuemin, aria-valuemax, aria-controls and an aria-orientation perpendicular 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 minSize so no pane becomes unreadable.
Keyboard interactions
KeysAction
←→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%.
HomeShrinks the panel before the handle to its minimum.
EndGrows the panel before the handle to its maximum.
EnterCollapses or expands the panel before the handle, if it is collapsible.
F6Moves focus to the next handle in the group; Shift+F6 goes back.

Design tokens#

Design tokens
TokenUsed for
--borderThe 1px handle and the grip
--ringThe handle's 1px focus ring
--radius-lgThe 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>).

Props of ResizablePanelGroup
PropTypeDefaultDescription
orientation"horizontal" | "vertical""horizontal"Side by side, or stacked.
defaultLayout{ [panelId: string]: number }No defaultStarting sizes by panel id, in percent. Restores a saved layout.
onLayoutChange(layout: Layout) => voidNo defaultCalled on every change, including each pointer move.
onLayoutChanged(layout: Layout, meta: LayoutChangedMeta) => voidNo defaultCalled 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.
disabledbooleanNo defaultTurns off resizing for the whole group.
disableCursorbooleanNo defaultStops the library from setting resize cursors.
groupRefRef<GroupImperativeHandle | null>No defaultgetLayout() and setLayout(layout).

ResizablePanel

One pane.

Other props spread onto react-resizable-panels Panel (<div>).

Props of ResizablePanel
PropTypeDefaultDescription
idstring | numberNo defaultIdentifies the panel in layouts. Falls back to useId.
defaultSizenumber | stringNo defaultStarting size. Numbers are pixels, strings are percent unless they carry a unit.
minSizenumber | string"0%"Smallest size a drag or key press can reach.
maxSizenumber | string"100%"Largest size.
collapsiblebooleanNo defaultLets the panel snap to collapsedSize when dragged below minSize.
collapsedSizenumber | string"0%"Size when collapsed.
collapsedThresholdnumber | stringNo defaultHow 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) => voidNo defaultCalled when this panel's size changes.
panelRefRef<PanelImperativeHandle | null>No defaultcollapse(), expand(), getSize(), isCollapsed() and resize(size).
disabledbooleanNo defaultStops 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">).

Props of ResizableHandle
PropTypeDefaultDescription
withHandlebooleanNo defaultAdds the centered grip pill.
disabledbooleanNo defaultStops this handle from resizing its panels.
disableDoubleClickbooleanNo defaultTurns off reset-on-double-click.
classNamestringNo defaultMerged 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.