Resizable
A split-pane layout: two or more panels in a row or a column, with a draggable, keyboard-operable handle between each pair. Built for master/detail screens, editors with a terminal, and inspector sidebars. Sizes are in px, in %, or both, and the layout can be saved and restored with no storage code in the component.
Import
import { Resizable, sanitizeLayout } from "chunks-ui";Basic Usage
Resizable.Group holds the panels. Put a Resizable.Handle between each two. Every panel needs a stable id: the name its size goes by in a layout. The id is a layout key only. It is not the panel’s DOM id: the DOM id is generated, so two groups on one page (or in tabs, or in a list of cards) can reuse the same names without a clash. The panel carries its key as data-panel-id, which is what a test or a style rule can select by. Keep each id unique within its group. __proto__ is not a valid id, and a panel that uses it is treated as having none: it still renders and resizes, under a generated key that onLayoutChange reports but a saved layout can’t count on (sanitizeLayout drops it), and development builds warn. Give the group a size, since it fills its parent:
<Resizable.Group className="h-48 w-full" aria-label="Sidebar and content">
<Resizable.Panel id="side" defaultSize="30%" minSize="15%" maxSize="60%">
Sidebar
</Resizable.Panel>
<Resizable.Handle aria-label="Resize sidebar" />
<Resizable.Panel id="main">Content</Resizable.Panel>
</Resizable.Group>Drag a handle, or focus it and use the keys below. A handle resizes the panel before it and the panel after it. A drag or key step commits the layout as the group shows it, so a panel the group had to fit (held at a px minSize, shrunk or stretched) keeps what it showed: a px panel its px size, a % panel the share of the group it had then, which grows and shrinks with the group from there.
Vertical
Set orientation="vertical" on the group to stack the panels. The handle lines then run horizontally, and the up and down arrow keys move them. A vertical group fills its parent’s height, so give the group or its parent a height (h-64 below). Without one the group is only as tall as its content, and a drag changes nothing on screen.
<Resizable.Group orientation="vertical" className="h-64 w-full" aria-label="Editor and terminal">
<Resizable.Panel id="editor" defaultSize="65%" minSize="20%">
Editor
</Resizable.Panel>
<Resizable.Handle aria-label="Resize terminal" />
<Resizable.Panel id="terminal" minSize="15%">
Terminal
</Resizable.Panel>
</Resizable.Group>Sizes: px and %
defaultSize, minSize and maxSize take a number (px) or a string with a unit: 240, "240px" and "30%" are all valid. The unit of defaultSize is the unit of the panel: it decides how the panel’s value reads in a layout (see Layout). A panel without a defaultSize is a % panel.
- A px panel keeps its size when the group resizes, as long as the % panels can take the change.
- A % panel takes a share of what the px panels and the handles leave. In a group with no px panel that is a share of the whole group. The shares of a group’s % panels always add up to 100: values that don’t are scaled, so
20%and20%are two halves. A % panel grows and shrinks with the group. minSizeandmaxSizeare limits in either unit, whatever the unit of the panel. A limit in % is a percent of the group (minus its handles). Both neighbours’ limits bind a handle, so it stops when either panel reaches one.- When the % panels can’t take all of the change (there are none, or they are at their
minSizeormaxSize), the px panels share the rest. A group of px panels only still fills its width, up to theirmaxSizes:200and300show at about 450px and 550px in a 1000px group. - A px panel wider than the group is shrunk to fit. A % panel with a px
minSizethe group is too small for is held at that size, and goes back to its share when the group grows. A drag or key step in between commits the share it is held at, and the panel keeps that share: a34%panel held at 300px in a 500px group keeps 60%, about 720px of a 1200px group.
<Resizable.Group className="h-48 w-full" aria-label="Folders, list and detail">
<Resizable.Panel id="folders" defaultSize={160} minSize={120} maxSize={280}>
Folders
</Resizable.Panel>
<Resizable.Handle aria-label="Resize folders" />
<Resizable.Panel id="list" defaultSize="40%" minSize="20%">
List
</Resizable.Panel>
<Resizable.Handle aria-label="Resize list" />
<Resizable.Panel id="detail" defaultSize="60%" minSize="20%">
Detail
</Resizable.Panel>
</Resizable.Group>Don’t put padding or a border on a Resizable.Panel. The browser takes them off before it shares the space, which skews the sizes and makes the handle drift from the pointer. Put them on an element inside the panel, as the examples do. Development builds warn when a panel has either.
The group may be a flex box with a gap (gap-2, say): the measure takes the gap between its children off before sharing the space, so the handle still follows the pointer. A gap in % is not counted. Padding and border on the group itself are counted off too.
Nested groups
For a layout that splits both ways, put a group inside a panel. Each group has its own orientation, handles and layout:
<Resizable.Group className="h-64 w-full" aria-label="Files and workspace">
<Resizable.Panel id="files" defaultSize="30%" minSize="15%">
Files
</Resizable.Panel>
<Resizable.Handle aria-label="Resize files" />
<Resizable.Panel id="workspace" defaultSize="70%">
<Resizable.Group orientation="vertical" aria-label="Editor and terminal">
<Resizable.Panel id="editor" defaultSize="70%" minSize="20%">
Editor
</Resizable.Panel>
<Resizable.Handle aria-label="Resize terminal" />
<Resizable.Panel id="terminal" minSize="15%">
Terminal
</Resizable.Panel>
</Resizable.Group>
</Resizable.Panel>
</Resizable.Group>Grip
A 1px line is easy to miss. Resizable.Grip is an optional part you put inside a handle: a small rounded pill centred on the line with a few dots in it, so users can see it can be dragged.
<Resizable.Handle aria-label="Resize sidebar">
<Resizable.Grip />
</Resizable.Handle>The grip is decorative. It has aria-hidden="true", is not focusable and has pointer-events: none, so a press that lands on it starts the drag of the handle under it, and the handle keeps its role, values and keys. It follows the group’s orientation and the handle’s hover, drag and focus state, and it keeps a visible border in forced-colors mode. Pass className to restyle it.
It takes the props of a div; className replaces the library’s classes where they conflict (rounded-full for a round pill, say). The dots are drawn by CSS, so there is no icon to supply.
Layout and controlled use
A layout maps each panel id to its size, in the unit that panel declares: px for a px panel, a share for a % panel.
type ResizableLayout = Record<string, number>;
// { folders: 160, list: 40, detail: 60 }Three props on the group carry it:
defaultLayoutis where the group starts. It is read once, at the first render, so the server’s HTML has the right sizes too.layoutmakes the group controlled: it shows this layout, fitted to the panels’ limits and the group’s size. A change is reported toonLayoutChange, and applied only once you pass the new layout back. If you don’t, the panels return to your layout.onLayoutChange(layout)is called with the whole layout once per change a user commits: when the pointer is released after a drag, and on each key step. It is not called on mount, while a drag is in progress, when the window or the group resizes, or when a key can’t move the handle (it is at its limit). There is no per-pointer-move callback. A drag writes the sizes straight to the DOM and React hears of it on release.
import { type ResizableLayout, Resizable } from "chunks-ui";
import { useState } from "react";
function ControlledSplit() {
const [layout, setLayout] = useState<ResizableLayout>({ left: 50, right: 50 });
return (
<Resizable.Group layout={layout} onLayoutChange={setLayout} className="h-32 w-full">
<Resizable.Panel id="left" minSize="20%" maxSize="80%">
Left
</Resizable.Panel>
<Resizable.Handle aria-label="Resize left" />
<Resizable.Panel id="right" minSize="20%">
Right
</Resizable.Panel>
</Resizable.Group>
);
}{"left":50,"right":50} after 0 committed changes
Update the state synchronously in onLayoutChange, as above. A parent that applies the new layout later (inside startTransition, after a router navigation, after a request) makes the group snap back to the old layout on release and then jump to the new one when it arrives, because the group shows the layout it is given at every render. To save the layout somewhere slow, set the state at once and write to the slow place from an effect or a debounce.
Both props go through sanitizeLayout, so a layout that doesn’t fit the panels (an old one, say) never breaks the group. See sanitizeLayout.
Persistence
The component has no storage code. It gives you defaultLayout to start from and onLayoutChange to save, and sanitizeLayout to make what you read back safe. Three recipes follow.
Always run what you read through sanitizeLayout. Storage can hold anything: an older layout, a value another tab wrote, a hand-edited URL. Always wrap reads and writes of localStorage in try/catch: it throws in some private windows, when site data is blocked, and when the quota is full.
Version the key when a panel’s unit changes. A stored 30 means 30% for a % panel and 30px for a px panel, and sanitizeLayout can’t tell which one wrote it. After changing a panel from % to px (or back), change the key (panels-v1 to panels-v2), so old values are ignored. Adding, removing and reordering panels needs no new key: missing panels take their defaults and unknown ids are dropped.
localStorage
For an app that renders only on the client, such as a Vite app. In a server-rendered app the server can’t read localStorage, so the first render would differ from the stored layout: use the cookie recipe.
import { type ResizablePanelConfig, type ResizableLayout, Resizable, sanitizeLayout } from "chunks-ui";
import { useState } from "react";
const PANELS = [
{ id: "list", defaultSize: "30%", minSize: "15%" },
{ id: "detail", defaultSize: "70%" },
] satisfies ResizablePanelConfig[];
// Version the key when a panel changes between px and %
const KEY = "panels-v1";
function readLayout(): ResizableLayout {
try {
return sanitizeLayout(JSON.parse(localStorage.getItem(KEY) ?? "null"), PANELS);
} catch {
return sanitizeLayout(null, PANELS);
}
}
function writeLayout(layout: ResizableLayout) {
try {
localStorage.setItem(KEY, JSON.stringify(layout));
} catch {
// Storage is blocked or full: the layout is simply not remembered
}
}
function MasterDetail() {
const [initial] = useState(readLayout);
return (
<Resizable.Group defaultLayout={initial} onLayoutChange={writeLayout} className="h-96 w-full">
<Resizable.Panel id="list" defaultSize="30%" minSize="15%">
List
</Resizable.Panel>
<Resizable.Handle aria-label="Resize list" />
<Resizable.Panel id="detail" defaultSize="70%">
Detail
</Resizable.Panel>
</Resizable.Group>
);
}Cookie with server rendering
The server reads the cookie and passes the layout as defaultLayout, so the first paint already has the saved sizes. With % panels and % limits only, that is the final layout: no flash, no jump after hydration. A px panel or a px limit can only be checked against the measured group, so a layout that doesn’t fit the group (the defaults in a narrow window too, or a layout saved at another window size) is corrected after hydration (see sanitizeLayout). Each panel’s size is the fallback of its CSS variable in its inline style, and defaultLayout is read at the first render. This is a Next.js App Router page; any server that can read the request’s cookies works the same.
A cookie that only remembers a layout is a functional preference: it holds something the user set and nothing that identifies them. It is often treated as needing no consent banner. The rules differ by country and some regulators ask for a short lifetime, so check the ones that apply to you.
// app/page.tsx (a server component)
import { cookies } from "next/headers";
import { sanitizeLayout } from "chunks-ui/utils";
import { MasterDetail } from "./master-detail";
import { COOKIE, PANELS } from "./panels";
export default async function Page() {
const stored = (await cookies()).get(COOKIE)?.value;
let raw: unknown = null;
try {
// Next has decoded the value already: it is the JSON the browser wrote
raw = JSON.parse(stored ?? "null");
} catch {
// A cookie that isn't JSON gives the defaults
}
return <MasterDetail defaultLayout={sanitizeLayout(raw, PANELS)} />;
}// app/panels.ts (no "use client": the server reads it too)
import type { ResizablePanelConfig } from "chunks-ui/utils";
export const COOKIE = "panels-v1"; // change it when a panel changes between px and %
export const PANELS = [
{ id: "list", defaultSize: "30%", minSize: "15%" },
{ id: "detail", defaultSize: "70%" },
] satisfies ResizablePanelConfig[];// app/master-detail.tsx
"use client";
import { type ResizableLayout, Resizable, sanitizeLayout } from "chunks-ui";
import { useState } from "react";
import { COOKIE, PANELS } from "./panels";
function readCookie(): ResizableLayout | undefined {
const pair = document.cookie.split("; ").find((part) => part.startsWith(`${COOKIE}=`));
if (!pair) return undefined;
try {
// Unlike the value the server reads, the browser's cookie string is still encoded
return sanitizeLayout(JSON.parse(decodeURIComponent(pair.slice(COOKIE.length + 1))), PANELS);
} catch {
return undefined;
}
}
function saveLayout(layout: ResizableLayout) {
const value = encodeURIComponent(JSON.stringify(layout));
// max-age is one year: shorten it if your rules ask for a shorter lifetime
document.cookie = `${COOKIE}=${value}; path=/; max-age=31536000; samesite=lax`;
}
export function MasterDetail({ defaultLayout }: { defaultLayout: ResizableLayout }) {
// On a first load the cookie is what the server read, so the layouts match and hydration is clean
const [initial] = useState(() =>
typeof document === "undefined" ? defaultLayout : (readCookie() ?? defaultLayout),
);
return (
<Resizable.Group defaultLayout={initial} onLayoutChange={saveLayout} className="h-96 w-full">
<Resizable.Panel id="list" defaultSize="30%" minSize="15%">
List
</Resizable.Panel>
<Resizable.Handle aria-label="Resize list" />
<Resizable.Panel id="detail" defaultSize="70%">
Detail
</Resizable.Panel>
</Resizable.Group>
);
}Reading cookies() makes the route render on every request (dynamic rendering). With cacheComponents on, read it inside a <Suspense> boundary, or the build fails. Next decodes cookie values, so the page parses value as it is; on a server that doesn’t, decode it yourself first.
The client component reads the cookie again in its useState initializer. On a first load that is the value the server read, so hydration matches. Back and Forward reuse the server’s first answer for a page, which can be older than the cookie, and the client’s read makes the newer layout win. The browser’s cookie string is still encoded, so readCookie decodes the value before it parses it.
The server page imports sanitizeLayout from chunks-ui/utils, not from chunks-ui. The main entry starts with "use client", so a Server Component that imports sanitizeLayout from it gets a client reference, and calling that on the server throws. chunks-ui/utils is built without the banner and holds the React-free helpers, sanitizeLayout and the ResizableLayout and ResizablePanelConfig types among them, so a server can call them. PANELS and COOKIE live in a plain module for the same reason: a module marked "use client" hands the server a reference too, not the value. The server still renders each panel’s defaultSize as the fallback for a panel with no stored value, so give every % panel a defaultSize.
URL
A layout in the query string survives a reload and can be shared as a link. history.replaceState changes the address without adding a history entry for every drag. Browsers limit how often it can be called: Safari throws a SecurityError after 100 calls in 10 seconds, and Firefox ignores calls past its limit. A held arrow key commits once per repeat, so debounce the write and keep the try/catch for Safari. Like the localStorage recipe, this one is for a page that renders only on the client: readUrl reads location, which a server doesn’t have. useState(readUrl) reads the URL once, on the first render, not on every render.
import { type ResizablePanelConfig, type ResizableLayout, Resizable, sanitizeLayout } from "chunks-ui";
import { useState } from "react";
const PANELS = [
{ id: "list", defaultSize: "30%", minSize: "15%" },
{ id: "detail", defaultSize: "70%" },
] satisfies ResizablePanelConfig[];
const PARAM = "panels-v1";
function readUrl(): ResizableLayout {
try {
const stored = new URL(location.href).searchParams.get(PARAM);
return sanitizeLayout(JSON.parse(stored ?? "null"), PANELS);
} catch {
return sanitizeLayout(null, PANELS);
}
}
let timer: ReturnType<typeof setTimeout> | undefined;
// Writes once the changes stop: a held arrow key commits on every repeat
function writeUrl(layout: ResizableLayout) {
const page = location.pathname;
clearTimeout(timer);
timer = setTimeout(() => {
// A router went to another page in the meantime: this layout is not that page's
if (location.pathname !== page) return;
const url = new URL(location.href);
url.searchParams.set(PARAM, JSON.stringify(layout));
try {
history.replaceState(history.state, "", url);
} catch {
// Safari throws a SecurityError when replaceState is called too often
}
}, 300);
}
function UrlSplit() {
const [initial] = useState(readUrl);
return (
<Resizable.Group defaultLayout={initial} onLayoutChange={writeUrl} className="h-96 w-full">
<Resizable.Panel id="list" defaultSize="30%" minSize="15%">
List
</Resizable.Panel>
<Resizable.Handle aria-label="Resize list" />
<Resizable.Panel id="detail" defaultSize="70%">
Detail
</Resizable.Panel>
</Resizable.Group>
);
}A change made less than 300ms before the user leaves or reloads the page is not written.
On a server-rendered page don’t call readUrl during render. Read the same parameter from the request on the server, as the cookie recipe does, and pass it down.
sanitizeLayout
function sanitizeLayout(raw: unknown, panels: readonly ResizablePanelConfig[]): ResizableLayout;Makes a layout that came from storage safe to use. panels is a list of { id, defaultSize?, minSize?, maxSize? }, the same fields as the panels’ props. It never throws.
- Input that is not a plain object (
null, a string, an array,undefined) gives the defaults. - Ids that match no panel are dropped.
- A panel whose
idis empty or__proto__, or that repeats theidof an earlier panel, is skipped. - A missing value, or one that is not a finite, non-negative number, is filled from the panel’s default. Missing % panels take what the others leave of 100.
- A px panel is clamped to its px
minSizeandmaxSize. - The % shares are scaled to add up to 100. In a group of % panels only, they are also clamped to the panels’ %
minSizeandmaxSize, and the difference goes to the panels that have room, so the result is inside the limits and still adds up to 100 ({ side: 99, main: 1 }withsideat most60%gives{ side: 60, main: 40 }). - When the group has a px panel, a % limit is a percent of the measured size, which the server doesn’t have.
sanitizeLayoutthen leaves % limits out, and the group applies them once it has measured. So does a limit in the other unit of a panel.
Keyboard
A handle is a focusable separator. These keys work with it focused:
| Key | Action |
|---|---|
ArrowRight / ArrowLeft | In a horizontal group, move the handle by 1% of the group. In RTL the two swap, so the key that points toward the end of the line still moves the handle that way. |
ArrowDown / ArrowUp | In a vertical group, move the handle by 1% of the group. |
Shift + an arrow | Move by 10% of the group. |
Home | Move to the smallest size of the panel before the handle, or as far as the panel after it allows. |
End | Move to the largest size of the panel before the handle, or as far as the panel after it allows. |
Each key press that moves the handle is one committed change: onLayoutChange is called once per press, also while a key is held. A press that can’t move the handle calls nothing. The arrows of the other axis do nothing.
Accessibility
Resizable.Handle follows the WAI-ARIA window splitter pattern:
role="separator", focusable withtabindex="0"once it has its values. A focusable separator needsaria-valuenow, so a handle without a panel on each side is not focusable, and neither is a handle in a group with a px panel until the group has measured.aria-orientationis the orientation of the separator line, as the pattern defines it: a horizontal group (panels side by side) has"vertical"lines. The handle’sdata-orientationhas the same value.aria-valuenowis the size of the panel before the handle, as a percent of the group.aria-valueminandaria-valuemaxare the range the handle can move that panel to, given the limits of both neighbours. They update while dragging.aria-controlslists the DOMids of the two panels it resizes (the generated ones, not the layout keys).- A disabled handle is out of the tab order and has
aria-disabled="true".
Give each handle an accessible name with aria-label (“Resize sidebar”) or aria-labelledby: the component has no built-in text, so there is nothing to translate. Resizable.Group has role="group"; label it too when the page holds more than one.
The server’s HTML has the panels’ sizes but not the handles’ aria-value* and aria-controls: they depend on the panels’ order and the group’s measured size, so they appear at hydration. A handle joins the tab order then too.
Right to left
Everything follows the writing direction, which the component reads from the group’s computed direction. In RTL the first panel sits at the right, dragging toward the left grows it, and the horizontal arrow keys swap. Home and End do not: they go to the smallest and the largest size of the panel before the handle. Nothing in the component uses a physical left or right, so a dir="rtl" on any ancestor is enough.
Pointer and touch
A drag uses pointer events with pointer capture, so it works with a mouse, a pen and a finger, and keeps going when the pointer leaves the thin handle. The handle has touch-action: none so a touch drag doesn’t scroll the page. Its hit area is wider than the line: 12px with a mouse and 44px on a touch screen (the touch-target utility). The hit area reaches about 6px (mouse) or 22px (touch) into each neighbouring panel and takes presses there, so keep buttons and links that far from the line. While dragging, the page’s text selection is switched off and the cursor stays a resize cursor. A drag that is cancelled (the system takes the pointer, the component unmounting) puts the sizes back and calls nothing.
Styling
The handle is a 1px line in bg-border that turns bg-ring on hover and while dragging. Pass className to change it. State is on data attributes, so use Tailwind’s data-* variants:
| Part | Attribute | Meaning |
|---|---|---|
Resizable.Group | data-orientation | horizontal or vertical: the axis of the panels |
Resizable.Handle | data-orientation | The orientation of its line, as aria-orientation: vertical in a horizontal group |
Resizable.Panel | data-panel-id | The panel’s id, its layout key |
Resizable.Handle | data-dragging | Present while a drag is in progress |
Resizable.Handle | data-disabled | Present when disabled |
A Resizable.Grip reads these from its handle (group-data-dragging/handle:, group-data-[orientation=vertical]/handle:), so a handle you restyle can restyle its grip the same way.
<Resizable.Handle
aria-label="Resize sidebar"
className="data-dragging:bg-primary data-[orientation=vertical]:w-0.5"
/>Not supported
- Collapsing a panel to zero (a handle that snaps it shut). A panel stops at its
minSize, which is 0 when unset, so a panel without one can be dragged down to 0. For a collapsible sidebar, render the panel and its handle, or neither. - Motion. Resizing is direct manipulation, and sizes never animate.
- Resetting on double click. To offer a reset, keep the layout in state (the controlled
layoutprop) and set it back. - A storage hook. The recipes above are a few lines each; the component stays free of storage code.
- Padding or a border on a panel. See Sizes.
Props
Resizable.Group
A div with role="group". It takes the props of a div, plus:
Resizable.Panel
A div. It takes the props of a div, plus the required id, which is a layout key and not the DOM id:
Resizable.Handle
A div with role="separator". It takes the props of a div, plus:
| Name | Type | Default |
|---|---|---|
disabled | booleanStops this handle from resizing. It drops out of the tab order and shows the not-allowed cursor. | false |
Resizable.Grip
An optional, decorative div that goes inside a Resizable.Handle. It takes the props of a div and no others; aria-hidden is always "true".