Resizable
Panels split by a draggable divider — the shadcn/ui resizable, on react-resizable-panels. Pairs with File Preview: the conversation and the file panel share one group, so the reader picks the split.
Conversation
Drag the divider, or focus it and press the arrow keys.
Details
Double-click the divider to reset both panels.
Installation
npx shadcn@latest add miskibin/chat-components/resizableUsage
"use client"
import { FilePreview } from "@/components/ui/file-preview"
import { MessageList } from "@/components/ui/message-list"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
export function Workspace({ messages, file, onClose }: WorkspaceProps) {
// The remembered width rides in on defaultSize; see "Remember the split".
const [previewSize, setPreviewSize] = useState(35)
return (
<ResizablePanelGroup
id="workspace"
orientation="horizontal"
onLayoutChanged={(layout) => save(layout.preview)}
className="min-w-0 flex-1"
>
<ResizablePanel id="chat" defaultSize={`${100 - previewSize}%`} minSize="40%">
<MessageList messages={messages} />
</ResizablePanel>
{file ? (
<>
<ResizableHandle withHandle />
<ResizablePanel
id="preview"
defaultSize={`${previewSize}%`}
minSize="20%"
maxSize="60%"
>
<FilePreview file={file} onClose={onClose} className="border-l" />
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
)
}Examples
Remember the split
onLayoutChanged fires once a drag ends, with the layout as a map of panel id to percentage — save the number you care about and feed it back through defaultSize. Do it this way whenever a panel is conditionally mounted: a group adopts defaultLayout only for the panels it mounted with, so a layout naming a panel that is not on screen yet is ignored. For a group whose panels are always there, useDefaultLayout (from react-resizable-panels) does the whole round-trip for you.
// Read after mount: localStorage does not exist while the page prerenders.
const [previewSize, setPreviewSize] = useState(35)
useEffect(() => {
const saved = Number(localStorage.getItem("workspace:preview-size"))
if (saved >= 20 && saved <= 60) queueMicrotask(() => setPreviewSize(saved))
}, [])
<ResizablePanelGroup
id="workspace"
onLayoutChanged={(layout) => {
if (layout.preview != null) {
localStorage.setItem("workspace:preview-size", String(layout.preview))
}
}}
>Restyle the divider
The state lives on data-separator, so a wider, louder handle is a class list away.
<ResizableHandle
withHandle
className="w-0.5 data-[separator=hover]:bg-primary data-[separator=active]:bg-primary
[&_[data-slot=resizable-grip]]:h-6"
/>Notes
API reference
ResizablePanelGroup
orientation"horizontal" | "vertical" = "horizontal"- Resize direction. Vertical groups stack — the class list flips them with
aria-[orientation=vertical]:flex-col. idstring | number- Identifies the group; required if you persist its layout. Falls back to useId.
defaultLayoutLayout- Starting split, as a map of panel id to percentage — adopted only when it names exactly the panels the group mounted with. Feed it from
useDefaultLayoutto restore a saved one. onLayoutChanged(layout, meta) => void- Fires once the drag ends — where you save the layout. onLayoutChange fires on every pointer move instead.
ResizablePanel
idstring | number- Identifies the pane inside the group — this is what makes a conditionally mounted panel keep its size, and what a saved layout is keyed by.
defaultSizenumber | string- Bare numbers are pixels; strings are percentages ("35" and "35%" are both 35% of the group). Other CSS units work too — "20rem", "40vh".
minSize / maxSizenumber | string- Resize bounds, in the same units as defaultSize.
collapsible / collapsedSizeboolean / number | string- Snap the pane shut once it is dragged under minSize, down to collapsedSize (0 by default).
classNamestring- Lands on the pane's inner content wrapper — the outer element belongs to the layout engine. Style the pane itself through
[data-slot=resizable-panel].
ResizableHandle
withHandleboolean- Renders the centred grip, so the divider reads as draggable. Rotated automatically in a vertical group.
disabledboolean- Freezes this divider; the panels can still be resized by others.
Data slots
Every part below is addressable from a parent, so you can restyle internals without forking the component.
resizable-panel-groupresizable-panelresizable-handleresizable-grip