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.

Open in

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/resizable

Usage

"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 useDefaultLayout to 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