Skip to content
Dethink Components

ComponentsResizable Panels

Resizable Panels

Make room for the task. Compose, resize, focus and restore your workspace.

On this page

Install component source from the public registry, or use the local workspace package. Follow the setup guide first. The npm package is not published yet.

View registry files
  • React Resizable Panels (react-resizable-panels)

    Constraint-based pane sizing, nested groups, pointer/touch resizing and keyboard-accessible separators.

Shared foundation: React, clsx for conditional classes, tailwind-merge for resolving utility-class conflicts. Styles use Tailwind CSS and Dethink CSS variables.

Based on this component’s source and registry composition. Libraries used only in showcase examples are excluded.

Import the component into your page or component file.

Usage
import { ResizablePanelGroup, ResizablePanel, ResizableHandle, ResizableWorkspace } from "@dethink/components";

Start with a divider. Build a workspace that remembers how you work.

Research studio

Evidence, synthesis and a working draft. Hide a pane, focus your writing and restore the layout without losing your draft.

Fieldnotes / Research studio

Room to think.

Keep the evidence close. Give the draft more space.

Working session
Product discovery

Use arrow keys on a divider to resize. Home and End reach size limits. Enter toggles a collapsible pane. Use the layout controls to reopen hidden panes.

Sources

A small library of signals

Select a source to bring its evidence into the conversation.

3 sources · private workspace

Synthesis

From signals to a point of view

What would make this workspace meaningfully better for our customers?
FFieldnotesResearch assistant

Start with continuity. People should be able to move from evidence to a decision without rebuilding their context.

Teams switch between five tools to answer one customer question. Context is lost at every handoff.

Customer interviews · 12 interviews

Try widening the draft, hiding Sources, or focusing a pane. Your draft stays where you left it.

Grounded in your selected source

Working draft

Make the next step concrete

Draft content is not stored across reloads. Only your pane layout is remembered.

17 words · kept in this session

Drag a divider or focus it and use arrow keys. Hide, focus and restore without losing your place.

Show sourceexamples/resizable/workbench.tsx
examples/resizable/workbench.tsx
"use client";

import { useState } from "react";
import { ResizableWorkspace, Badge } from "@dethink/components";

const sources = [
  {
    title: "Customer interviews",
    kind: "Research · 12 conversations",
    detail:
      "Teams switch between five tools to answer one customer question. Context is lost at every handoff.",
    count: "12 interviews",
  },
  {
    title: "Usage patterns",
    kind: "Analytics · last 30 days",
    detail:
      "Returning users spend most of their time in the review queue. Faster context switching is more valuable than another dashboard.",
    count: "30-day window",
  },
  {
    title: "Product principles",
    kind: "Strategy · working document",
    detail:
      "Make the next action clear. Keep evidence close to the decision. Let people arrange their workspace around the task.",
    count: "3 principles",
  },
];

export function ResizableWorkbench() {
  const [selected, setSelected] = useState(0);
  const [draft, setDraft] = useState(
    "Give every decision its context.\n\nBring customer evidence, discussion and a working draft into one adaptable workspace.",
  );
  const [showEvidence, setShowEvidence] = useState(true);
  return (
    <div className="w-full space-y-4 p-4">
      <div className="flex flex-wrap items-end justify-between gap-3">
        <div>
          <p className="text-muted-foreground text-xs tracking-widest uppercase">
            Fieldnotes / Research studio
          </p>
          <h3 className="mt-2 text-xl font-semibold tracking-tight">
            Room to think.
          </h3>
          <p className="text-muted-foreground mt-1 text-sm">
            Keep the evidence close. Give the draft more space.
          </p>
        </div>
        <Badge variant="soft">Working session</Badge>
      </div>
      <ResizableWorkspace
        id="research-studio"
        label="Product discovery"
        storageKey="dethink-research-layout-v1"
        compactAt={560}
        className="h-[34rem] data-[compact]:h-auto"
        panes={[
          {
            id: "sources",
            title: "Sources",
            description: "A small library of signals",
            defaultSize: "25%",
            minSize: "18%",
            collapsible: true,
            footer: <span>{sources.length} sources · private workspace</span>,
            children: (
              <div className="space-y-2">
                {sources.map((source, index) => (
                  <button
                    type="button"
                    key={source.title}
                    aria-pressed={selected === index}
                    onClick={() => setSelected(index)}
                    className="border-border hover:bg-muted aria-pressed:border-primary/40 aria-pressed:bg-primary/5 focus-visible:outline-ring w-full rounded-lg border border-transparent p-3 text-start focus-visible:outline-2"
                  >
                    <span className="text-muted-foreground mb-3 block font-mono text-[10px]">
                      0{index + 1} / SOURCE
                    </span>
                    <span className="block text-sm font-medium">
                      {source.title}
                    </span>
                    <span className="text-muted-foreground mt-1 block text-xs leading-relaxed">
                      {source.kind}
                    </span>
                  </button>
                ))}
                <p className="text-muted-foreground border-border mt-5 border-t pt-4 text-xs leading-relaxed">
                  Select a source to bring its evidence into the conversation.
                </p>
              </div>
            ),
          },
          {
            id: "conversation",
            title: "Synthesis",
            description: "From signals to a point of view",
            defaultSize: "40%",
            minSize: "25%",
            footer: <span>Grounded in your selected source</span>,
            children: (
              <div className="space-y-6">
                <div className="bg-muted ms-6 rounded-xl rounded-tr-sm p-4 text-sm leading-relaxed">
                  What would make this workspace meaningfully better for our
                  customers?
                </div>
                <div>
                  <div className="mb-3 flex items-center gap-2">
                    <span className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md text-xs">
                      F
                    </span>
                    <span className="text-xs font-medium">Fieldnotes</span>
                    <span className="text-muted-foreground text-xs">
                      Research assistant
                    </span>
                  </div>
                  <p className="text-sm leading-7">
                    Start with continuity. People should be able to move from
                    evidence to a decision without rebuilding their context.
                  </p>
                  <button
                    type="button"
                    aria-expanded={showEvidence}
                    onClick={() => setShowEvidence(!showEvidence)}
                    className="text-primary my-3 rounded text-xs underline underline-offset-4 focus-visible:outline-2"
                  >
                    {showEvidence ? "Hide evidence" : "Show evidence"}
                  </button>
                  {showEvidence && (
                    <blockquote className="border-primary/40 bg-muted/40 rounded-r-lg border-s-2 p-4">
                      <p className="text-sm leading-relaxed">
                        {sources[selected].detail}
                      </p>
                      <footer className="text-muted-foreground mt-3 text-xs">
                        {sources[selected].title} · {sources[selected].count}
                      </footer>
                    </blockquote>
                  )}
                  <p className="text-muted-foreground mt-4 text-xs leading-relaxed">
                    Try widening the draft, hiding Sources, or focusing a pane.
                    Your draft stays where you left it.
                  </p>
                </div>
              </div>
            ),
          },
          {
            id: "draft",
            title: "Working draft",
            description: "Make the next step concrete",
            defaultSize: "35%",
            minSize: "22%",
            footer: (
              <span className="tabular-nums">
                {draft.trim().split(/\s+/).filter(Boolean).length} words · kept
                in this session
              </span>
            ),
            children: (
              <div className="flex h-full min-h-52 flex-col">
                <label
                  htmlFor="research-draft"
                  className="text-muted-foreground mb-3 text-xs font-medium tracking-wider uppercase"
                >
                  Opportunity statement
                </label>
                <textarea
                  id="research-draft"
                  value={draft}
                  onChange={(event) => setDraft(event.target.value)}
                  className="focus-visible:ring-ring min-h-40 flex-1 resize-none rounded-lg bg-transparent p-1 text-sm leading-7 outline-none focus-visible:ring-2"
                />
                <p className="text-muted-foreground border-border mt-4 border-t pt-3 text-xs">
                  Draft content is not stored across reloads. Only your pane
                  layout is remembered.
                </p>
              </div>
            ),
          },
        ]}
      />
      <p className="text-muted-foreground text-xs">
        Drag a divider or focus it and use arrow keys. Hide, focus and restore
        without losing your place.
      </p>
    </div>
  );
}

Operations desk

A filtered run queue beside a nested execution view and event stream. Independent scrolling keeps each pane useful.

Operations desk

Use arrow keys on a divider to resize. Home and End reach size limits. Enter toggles a collapsible pane. Use the layout controls to reopen hidden panes.

Run queue

4 workflows

Run details

Daily account sync

Daily account syncHealthy

Execution overview

1,284 records processed

  1. 01Fetch source records
  2. 02Validate and reconcile
  3. 03Publish updated accounts
Event stream

09:41:00 · Connected to source

09:41:02 · Validation complete

09:41:04 · All records synchronized

Show sourceexamples/resizable/operations.tsx
examples/resizable/operations.tsx
"use client";

import { useState } from "react";
import {
  ResizableWorkspace,
  ResizablePanelGroup,
  ResizablePanel,
  ResizableHandle,
  ResizablePanelHeader,
  ResizablePanelBody,
  Badge,
} from "@dethink/components";

const runs = [
  "Daily account sync",
  "Invoice reconciliation",
  "Customer health digest",
  "Archive completed work",
];
export function ResizableOperations() {
  const [query, setQuery] = useState("");
  const [selected, setSelected] = useState(runs[0]);
  const [paused, setPaused] = useState(false);
  return (
    <ResizableWorkspace
      id="operations-desk"
      label="Operations desk"
      compactAt={560}
      className="h-[32rem] data-[compact]:h-auto"
      panes={[
        {
          id: "queue",
          title: "Run queue",
          defaultSize: "32%",
          minSize: "22%",
          collapsible: true,
          footer: <span>{runs.length} workflows</span>,
          children: (
            <div>
              <label
                htmlFor="run-filter"
                className="text-muted-foreground mb-2 block text-xs"
              >
                Find a workflow
              </label>
              <input
                id="run-filter"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Search runs…"
                className="border-border focus-visible:ring-ring bg-background mb-4 w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
              />
              <div className="space-y-1">
                {runs
                  .filter((run) =>
                    run.toLowerCase().includes(query.toLowerCase()),
                  )
                  .map((run, index) => (
                    <button
                      type="button"
                      key={run}
                      aria-pressed={selected === run}
                      onClick={() => setSelected(run)}
                      className="hover:bg-muted aria-pressed:bg-muted focus-visible:outline-ring w-full rounded-md p-3 text-start focus-visible:outline-2"
                    >
                      <span className="text-muted-foreground block font-mono text-[10px]">
                        RUN / 00{index + 1}
                      </span>
                      <span className="mt-1 block text-sm font-medium">
                        {run}
                      </span>
                    </button>
                  ))}
              </div>
              {!runs.some((run) =>
                run.toLowerCase().includes(query.toLowerCase()),
              ) && (
                <p className="text-muted-foreground py-5 text-sm">
                  No matching workflows.
                </p>
              )}
            </div>
          ),
        },
        {
          id: "detail",
          title: "Run details",
          description: selected,
          defaultSize: "68%",
          minSize: "35%",
          actions: (
            <button
              type="button"
              onClick={() => setPaused(!paused)}
              className="text-primary min-h-8 rounded px-2 text-xs font-medium focus-visible:outline-2"
            >
              {paused ? "Resume" : "Pause"}
            </button>
          ),
          children: (
            <div className="h-full min-h-52">
              <ResizablePanelGroup orientation="vertical">
                <ResizablePanel
                  id="run-summary"
                  defaultSize="60%"
                  minSize="30%"
                >
                  <ResizablePanelHeader>
                    <span>{selected}</span>
                    <Badge variant="soft">
                      {paused ? "Paused" : "Healthy"}
                    </Badge>
                  </ResizablePanelHeader>
                  <ResizablePanelBody>
                    <p className="text-muted-foreground text-xs tracking-wider uppercase">
                      Execution overview
                    </p>
                    <p className="mt-3 text-3xl font-semibold tabular-nums">
                      1,284{" "}
                      <span className="text-muted-foreground text-sm font-normal">
                        records processed
                      </span>
                    </p>
                    <ol className="mt-5 space-y-3 text-sm">
                      {[
                        "Fetch source records",
                        "Validate and reconcile",
                        "Publish updated accounts",
                      ].map((step, index) => (
                        <li key={step} className="flex gap-3">
                          <span className="text-primary font-mono text-xs">
                            0{index + 1}
                          </span>
                          {step}
                        </li>
                      ))}
                    </ol>
                  </ResizablePanelBody>
                </ResizablePanel>
                <ResizableHandle aria-label="Execution overview" />
                <ResizablePanel id="run-events" minSize="20%">
                  <ResizablePanelHeader>Event stream</ResizablePanelHeader>
                  <ResizablePanelBody>
                    <div className="space-y-2 font-mono text-[11px] leading-relaxed">
                      <p className="text-muted-foreground">
                        09:41:00 · Connected to source
                      </p>
                      <p className="text-muted-foreground">
                        09:41:02 · Validation complete
                      </p>
                      <p className="text-primary">
                        09:41:04 ·{" "}
                        {paused
                          ? "Execution paused by operator"
                          : "All records synchronized"}
                      </p>
                    </div>
                  </ResizablePanelBody>
                </ResizablePanel>
              </ResizablePanelGroup>
            </div>
          ),
        },
      ]}
    />
  );
}

Chat and canvas

The Chat component beside an editable brief. Send a sample request, resize the canvas, or focus a pane without losing the conversation.

Studio / Chat and canvas

Talk it through. Shape it here.

A conversation on the left. Room for the work on the right.

Interactive demo
Launch studio

Use arrow keys on a divider to resize. Home and End reach size limits. Enter toggles a collapsible pane. Use the layout controls to reopen hidden panes.

Chat

Think alongside your draft

  1. Studio
    Your launch brief is ready on the right. Ask for a checklist, or edit the brief directly. Give either pane more room whenever you need it.
Local demo · sample replies · nothing is sent

Launch brief

An editable canvas

BRIEF / 001Draft
Session draft · resize without losing your edits
Show sourceexamples/resizable/chat.tsx
examples/resizable/chat.tsx
"use client";

import { useState } from "react";
import {
  Badge,
  Button,
  Chat,
  ResizableWorkspace,
  type ChatMessageData,
} from "@dethink/components";

const conversationId = "launch-studio-chat";

export function ResizableChat() {
  const [draft, setDraft] = useState("");
  const [brief, setBrief] = useState(
    "A calmer place to do focused work.\n\nLaunch a workspace that keeps conversation and the work itself side by side. Start with a small customer pilot, learn from real sessions, and expand when the experience feels right.",
  );
  const [checklist, setChecklist] = useState(false);
  const [request, setRequest] = useState("");
  const [messages, setMessages] = useState<ChatMessageData[]>([
    {
      id: "welcome",
      conversationId,
      role: "assistant",
      name: "Studio",
      parts: [
        {
          id: "text",
          type: "text",
          text: "Your launch brief is ready on the right. Ask for a checklist, or edit the brief directly. Give either pane more room whenever you need it.",
        },
      ],
    },
  ]);

  return (
    <div className="w-full space-y-4 p-4">
      <div className="flex flex-wrap items-end justify-between gap-3">
        <div>
          <p className="text-muted-foreground text-xs tracking-widest uppercase">
            Studio / Chat and canvas
          </p>
          <h3 className="mt-2 text-xl font-semibold tracking-tight">
            Talk it through. Shape it here.
          </h3>
          <p className="text-muted-foreground mt-1 text-sm">
            A conversation on the left. Room for the work on the right.
          </p>
        </div>
        <Badge variant="soft">Interactive demo</Badge>
      </div>
      <ResizableWorkspace
        id="chat-studio"
        label="Launch studio"
        compactAt={640}
        className="h-[40rem] [--dt-resizable-compact-height:32rem] data-[compact]:h-auto [&_#chat-studio-conversation_[data-slot=resizable-panel-body]]:overflow-hidden [&_#chat-studio-conversation_[data-slot=resizable-panel-body]]:p-0"
        panes={[
          {
            id: "conversation",
            title: "Chat",
            description: "Think alongside your draft",
            defaultSize: "48%",
            minSize: "40%",
            children: (
              <Chat
                conversationId={conversationId}
                messages={messages}
                aria-label="Launch assistant"
                className="h-full"
                prompt={{
                  value: draft,
                  onValueChange: setDraft,
                  label: "Message the launch assistant",
                  placeholder: "Try: Add a launch checklist",
                  footer: "Local demo · sample replies · nothing is sent",
                  onSend: ({ text }) => {
                    const addsChecklist = /checklist/i.test(text);
                    if (addsChecklist) setChecklist(true);
                    setRequest(text);
                    const id = crypto.randomUUID();
                    setMessages((current) => [
                      ...current,
                      {
                        id,
                        conversationId,
                        role: "user",
                        parts: [{ id: "text", type: "text", text }],
                      },
                      {
                        id: `${id}-reply`,
                        conversationId,
                        role: "assistant",
                        name: "Studio",
                        parts: [
                          {
                            id: "text",
                            type: "text",
                            text: addsChecklist
                              ? "I added a launch checklist beside your brief. Your edits are still in place; tick off each step as you go."
                              : "I added your request to the canvas so you can keep it in view while editing. This demo uses sample replies; your brief remains yours to shape.",
                          },
                        ],
                      },
                    ]);
                  },
                }}
              />
            ),
          },
          {
            id: "canvas",
            title: "Launch brief",
            description: "An editable canvas",
            defaultSize: "52%",
            minSize: "30%",
            footer: (
              <span>Session draft · resize without losing your edits</span>
            ),
            children: (
              <div className="space-y-5">
                <div className="flex items-center justify-between gap-2">
                  <span className="text-muted-foreground font-mono text-xs">
                    BRIEF / 001
                  </span>
                  <Badge variant="soft">Draft</Badge>
                </div>
                <div>
                  <label
                    htmlFor="launch-brief"
                    className="mb-3 block text-lg font-semibold tracking-tight"
                  >
                    The next chapter of work
                  </label>
                  <textarea
                    id="launch-brief"
                    value={brief}
                    onChange={(event) => setBrief(event.target.value)}
                    className="focus-visible:ring-ring min-h-56 w-full resize-y rounded-lg bg-transparent p-1 text-sm leading-7 outline-none focus-visible:ring-2"
                  />
                </div>
                {checklist ? (
                  <fieldset className="border-border space-y-3 rounded-xl border p-4">
                    <legend className="px-1 text-sm font-medium">
                      Launch checklist
                    </legend>
                    {[
                      "Invite the pilot team",
                      "Schedule customer feedback",
                      "Review readiness together",
                    ].map((item) => (
                      <label
                        key={item}
                        className="flex items-start gap-3 text-sm leading-6"
                      >
                        <input
                          type="checkbox"
                          className="accent-primary mt-1 size-4 shrink-0"
                        />
                        {item}
                      </label>
                    ))}
                  </fieldset>
                ) : (
                  <Button
                    variant="outline"
                    size="sm"
                    onClick={() => setDraft("Add a launch checklist")}
                  >
                    Ask for a launch checklist
                  </Button>
                )}
                {request && (
                  <div className="border-border border-t pt-4">
                    <p className="text-muted-foreground text-xs font-medium">
                      Latest request
                    </p>
                    <p className="mt-2 text-sm leading-relaxed break-words">
                      {request}
                    </p>
                  </div>
                )}
              </div>
            ),
          },
        ]}
      />
    </div>
  );
}

Composable primitives

Two panes and a labelled handle. Nest another group inside a pane for mixed horizontal and vertical layouts.

Library

Give navigation just enough room. The divider works with a pointer, touch or keyboard.

Canvas
Space for your work
Show sourceexamples/resizable/basic.tsx
examples/resizable/basic.tsx
"use client";
import {
  ResizablePanelGroup,
  ResizablePanel,
  ResizableHandle,
  ResizablePanelHeader,
  ResizablePanelBody,
} from "@dethink/components";
export function ResizableBasic() {
  return (
    <div className="border-border h-64 w-full overflow-hidden rounded-xl border">
      <ResizablePanelGroup>
        <ResizablePanel defaultSize="35%" minSize="20%">
          <ResizablePanelHeader>Library</ResizablePanelHeader>
          <ResizablePanelBody>
            <p className="text-muted-foreground text-sm leading-relaxed">
              Give navigation just enough room. The divider works with a
              pointer, touch or keyboard.
            </p>
          </ResizablePanelBody>
        </ResizablePanel>
        <ResizableHandle aria-label="Library" />
        <ResizablePanel minSize="30%">
          <ResizablePanelHeader>Canvas</ResizablePanelHeader>
          <ResizablePanelBody>
            <div className="border-border text-muted-foreground flex h-full min-h-24 items-center justify-center rounded-lg border border-dashed text-sm">
              Space for your work
            </div>
          </ResizablePanelBody>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  );
}

Group orientation describes how panes are arranged. A horizontal group has a vertical divider. Use percentage strings such as “30%”, pixel numbers, or explicit CSS units for sizes. Give vertical groups a definite height and keep minimum sizes feasible for the container. Panel and Handle must be direct DOM children of Group.

Header, Body and Footer are optional building blocks. Body scrolls independently. ResizableWorkspace adds visible collapse, reopen, focus and restore controls around these primitives; it keeps pane content mounted. Supply stable pane IDs and a document-unique workspace ID.

Set storageKey to remember settled layouts. Saved data is versioned and validated against the pane IDs; blocked storage and invalid data fall back safely. Reset layout restores the initial arrangement. Focus mode does not overwrite your regular layout. Persistence stores sizes only, never pane content.

Set compactAt to opt into a stacked layout based on the workspace container width. Every pane stays available and keeps its state; resizing pauses until more space is available. Customize compact pane height with --dt-resizable-compact-height (default 22rem). Give the workspace an automatic height in compact mode, as the examples do.

Resizable API
PropWhat it doesDefault
Group: orientation"horizontal" | "vertical"Pane arrangement; divider orientation is perpendicular.horizontal
Panel: defaultSize / minSize / maxSizenumber | stringNumbers are pixels. Use explicit percentage strings for relative sizing.auto / 0% / 100%
Panel: collapsible / collapsedSizeboolean / number | stringEnables collapse. Provide a reachable external reopen control.false / 0%
Group: groupRef / Panel: panelRefResizableGroupHandle / ResizablePanelHandleRead/set layouts, resize, collapse and expand panels.Not set
Group: onLayoutChange / onLayoutChanged(layout, meta?) => voidContinuous or settled layout changes. Prefer settled updates for persistence.Not set
Handle: aria-label / withHandlestring / booleanAccessible primary-pane name and optional visible grip.— / true
Workspace: id / label / panesstring / string / ResizableWorkspacePane[]Document-unique workspace ID, visible label and at least two uniquely identified panes.Required
Workspace pane: title / children / actions / footerstring / ReactNodeContent, pane chrome and optional actions. Sizing and collapsible options are supported.Not set
Workspace: compactAtnumberOpt into stacked panes below this container width in pixels.Not set
Workspace: storageKey / storagestring / ResizableLayoutStorageOpt-in versioned size persistence; supply a synchronous storage adapter if desired.Off / localStorage
Workspace: onLayoutCommit(layout: ResizableLayout) => voidSettled normal layouts; temporary focus and compact arrangements are excluded.Not set

Name every divider after its primary pane. Tab to a divider, use arrows to resize, Home/End to reach its limits, and Enter to collapse a collapsible primary pane. Workspace controls provide a visible way to reopen panes. Focus mode removes other pane content from keyboard navigation and restores the initiating control when you return. Thin dividers have larger interaction targets. Theme tokens retain focus and boundaries in dark and high-contrast modes; resizing has no decorative motion.