"use client";
import * as React from "react";
/** A size: a number is a percentage of the group, or a string such as "240px" or "30%". */
export type PanelSize = number | `${number}px` | `${number}%`;
export interface ResizablePanel {
/** Stable id; also used for the panel's DOM id (aria-controls). */
id: string;
content: React.ReactNode;
/** Accessible name used for the separator ("Resize Sidebar"). Defaults to the id. */
label?: string;
/** Initial size in percent. Panels without one share the remaining space. */
defaultSize?: number;
minSize?: PanelSize;
maxSize?: PanelSize;
/** Can be collapsed by double-clicking or pressing Enter on a separator, by dragging past half its minimum, or via useResizablePanel(). */
collapsible?: boolean;
/** Size while collapsed (e.g. "44px" for an icon rail). Defaults to 0. */
collapsedSize?: PanelSize;
/** Start collapsed. */
defaultCollapsed?: boolean;
className?: string;
}
export interface ResizablePanelsProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
panels: ResizablePanel[];
/** Split direction: "horizontal" places panels side by side. */
direction?: "horizontal" | "vertical";
/** Initial sizes in percent (one per panel). Pass saved values from onLayout to restore a layout. */
defaultSizes?: number[];
/** Called with the sizes in percent whenever a resize is committed (drag end, keyboard, collapse). */
onLayout?: (sizes: number[]) => void;
/** Separator look. */
handle?: "line" | "grip" | "bar";
/** Percent moved per arrow key press (Shift moves 4×). */
keyboardStep?: number;
/** Duration of collapse / expand and keyboard moves, in ms. Dragging always follows the pointer. */
collapseDuration?: number;
/** Separator highlight and focus colour. */
accent?: string;
}
export interface ResizablePanelApi {
/** Current size in percent. */
size: number;
collapsed: boolean;
collapsible: boolean;
collapse: () => void;
expand: () => void;
toggle: () => void;
}
const PanelContext = React.createContext<ResizablePanelApi | null>(null);
/** Read and control the enclosing panel from its content (e.g. a collapse button in a header). */
export function useResizablePanel(): ResizablePanelApi {
const api = React.useContext(PanelContext);
if (!api) throw new Error("useResizablePanel must be used inside a ResizablePanels panel.");
return api;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
/* -------------------------------------------------------------------------- */
/* Layout maths (all sizes in percent of the space shared by the panels) */
/* -------------------------------------------------------------------------- */
const EPS = 0.01;
interface Constraint {
min: number;
max: number;
collapsed: number;
collapsible: boolean;
}
interface Layout {
sizes: number[];
collapsed: boolean[];
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const sum = (list: number[]) => list.reduce((a, b) => a + b, 0);
/** Converts a size spec to percent. Pixel values need the measured space (0 before the first measure). */
function toPercent(size: PanelSize | undefined, fallback: number, space: number) {
if (size === undefined) return fallback;
if (typeof size === "number") return size;
const n = parseFloat(size);
if (size.endsWith("%")) return n;
return space > 0 ? (n / space) * 100 : fallback;
}
function constraintsFor(panels: ResizablePanel[], space: number): Constraint[] {
return panels.map((p) => {
const min = clamp(toPercent(p.minSize, 0, space), 0, 100);
const max = clamp(toPercent(p.maxSize, 100, space), min, 100);
return { min, max, collapsed: clamp(toPercent(p.collapsedSize, 0, space), 0, min), collapsible: !!p.collapsible };
});
}
/** Clamps every panel to its constraints and spreads any surplus or deficit, nearest to `pivot` first. */
function fitLayout(sizes: number[], collapsed: boolean[], cons: Constraint[], pivot = -1): number[] {
const next = sizes.map((s, i) => (collapsed[i] ? cons[i].collapsed : clamp(s, cons[i].min, cons[i].max)));
let diff = 100 - sum(next);
const order = next
.map((_, i) => i)
.filter((i) => i !== pivot && !collapsed[i])
.sort((a, b) => (pivot < 0 ? b - a : Math.abs(a - pivot) - Math.abs(b - pivot) || a - b));
for (const i of order) {
if (Math.abs(diff) < EPS) break;
const room = diff > 0 ? cons[i].max - next[i] : cons[i].min - next[i];
const d = diff > 0 ? Math.min(diff, room) : Math.max(diff, room);
next[i] += d;
diff -= d;
}
// Impossible constraints: give the remainder to the pivot (or the last open panel) so sizes still add up.
if (Math.abs(diff) >= EPS) {
const target = pivot >= 0 ? pivot : Math.max(0, collapsed.lastIndexOf(false));
next[target] += diff;
}
return next;
}
/**
* Moves separator `handle` (between panels handle and handle + 1) by `delta` percent from `base`.
* The panel in front of the move grows; panels behind it shrink in order, down to their minimums.
* A collapsible neighbour dragged past half its minimum snaps shut, and a collapsed one snaps open.
*/
function moveHandle(base: number[], handle: number, delta: number, cons: Constraint[], keyboard = false): number[] {
if (Math.abs(delta) < EPS) return base;
const forward = delta > 0;
const grower = forward ? handle : handle + 1;
const chain: number[] = [];
if (forward) for (let i = handle + 1; i < base.length; i++) chain.push(i);
else for (let i = handle; i >= 0; i--) chain.push(i);
const g = cons[grower];
const growerCollapsed = g.collapsible && base[grower] <= g.collapsed + EPS;
let amount = Math.abs(delta);
if (growerCollapsed) {
// Pointer drags must pass half the minimum to snap open; a key press opens straight away.
if (!keyboard && amount < (g.min - g.collapsed) / 2) return base;
amount = Math.max(amount, g.min - g.collapsed);
}
const cap = g.max - base[grower];
amount = Math.min(amount, cap);
if (amount < EPS) return base;
const next = base.slice();
let remaining = amount;
chain.forEach((i, k) => {
if (remaining < EPS) return;
const c = cons[i];
const isCollapsed = c.collapsible && base[i] <= c.collapsed + EPS;
if (k === 0 && c.collapsible && !isCollapsed) {
const target = base[i] - Math.abs(delta);
const freed = base[i] - c.collapsed;
if (target < (c.min + c.collapsed) / 2 && freed <= cap + EPS) {
next[i] = c.collapsed;
remaining -= freed;
return;
}
}
const floor = isCollapsed ? c.collapsed : c.min;
const freed = Math.min(remaining, Math.max(0, base[i] - floor));
next[i] = base[i] - freed;
remaining -= freed;
});
const given = amount - remaining;
if (growerCollapsed && given < g.min - g.collapsed - EPS) return base;
next[grower] = base[grower] + given;
return next;
}
function initialLayout(panels: ResizablePanel[], defaultSizes?: number[]): Layout {
const collapsed = panels.map((p) => !!(p.collapsible && p.defaultCollapsed));
const cons = constraintsFor(panels, 0);
const given = panels.map((p, i) => (collapsed[i] ? cons[i].collapsed : (defaultSizes?.[i] ?? p.defaultSize)));
const fixed = sum(given.filter((s): s is number => s !== undefined));
const open = given.filter((s) => s === undefined).length;
const share = open ? Math.max(0, 100 - fixed) / open : 0;
const sizes = given.map((s) => s ?? share);
return { sizes: fitLayout(sizes, collapsed, cons), collapsed };
}
const round = (n: number) => Math.round(n * 100) / 100;
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
/**
* Split panes with draggable, keyboard-operable separators. Supports any number of panels, px or %
* min / max sizes, collapsible panels with snapping, nested groups and an onLayout callback for persistence.
*/
export function ResizablePanels({
panels,
direction = "horizontal",
defaultSizes,
onLayout,
handle = "grip",
keyboardStep = 5,
collapseDuration = 220,
accent = "#FF7448",
className = "",
style,
...props
}: ResizablePanelsProps) {
const uid = React.useId();
const reduceMotion = usePrefersReducedMotion();
const horizontal = direction === "horizontal";
const rootRef = React.useRef<HTMLDivElement>(null);
const panelRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const handleRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const restoreRef = React.useRef(new Map<number, number>());
const dragRef = React.useRef<{ handle: number; start: number; base: number[]; latest: number[]; cons: Constraint[]; space: number } | null>(null);
const [layout, setLayout] = React.useState<Layout>(() => initialLayout(panels, defaultSizes));
const [space, setSpace] = React.useState(0);
// Keep the layout in step with the panel count if panels are added or removed.
const [panelCount, setPanelCount] = React.useState(panels.length);
if (panels.length !== panelCount) {
setPanelCount(panels.length);
setLayout(initialLayout(panels, defaultSizes));
}
const cons = constraintsFor(panels, space);
const { sizes, collapsed } = layout;
// Effective limits for a separator's panel, given what every other panel can give or take.
const floorOf = (c: Constraint) => (c.collapsible ? c.collapsed : c.min);
const ariaLimits = (i: number) => {
const others = cons.filter((_, j) => j !== i);
const min = Math.max(floorOf(cons[i]), 100 - sum(others.map((c) => c.max)));
const max = Math.min(cons[i].max, 100 - sum(others.map(floorOf)));
return { min: Math.round(min), max: Math.round(Math.max(min, max)) };
};
const panelId = (i: number) => `${uid}-${panels[i]?.id ?? i}`;
/** Space shared by the panels (group size minus separators), in px. */
const measureSpace = React.useCallback(() => {
let total = 0;
for (const el of panelRefs.current) if (el) total += horizontal ? el.offsetWidth : el.offsetHeight;
return total;
}, [horizontal]);
// Re-measure on resize so pixel constraints stay true as the group grows or shrinks.
const onResize = React.useEffectEvent(() => {
const next = measureSpace();
if (next <= 0) return;
setSpace((prev) => (Math.abs(prev - next) < 0.5 ? prev : next));
setLayout((prev) => {
const fitted = fitLayout(prev.sizes, prev.collapsed, constraintsFor(panels, next));
return fitted.every((s, i) => Math.abs(s - prev.sizes[i]) < EPS) ? prev : { ...prev, sizes: fitted.map(round) };
});
});
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const ro = new ResizeObserver(() => onResize());
ro.observe(root);
return () => ro.disconnect();
}, []);
const commit = (nextSizes: number[], c: Constraint[], before: number[]) => {
const nextCollapsed = nextSizes.map((s, i) => c[i].collapsible && s <= c[i].collapsed + EPS);
nextCollapsed.forEach((isCollapsed, i) => {
// Remember the open size so expanding returns to it.
if (isCollapsed && !collapsed[i] && before[i] > c[i].collapsed + EPS) restoreRef.current.set(i, before[i]);
});
const rounded = nextSizes.map(round);
setLayout({ sizes: rounded, collapsed: nextCollapsed });
onLayout?.(rounded);
};
const currentConstraints = () => constraintsFor(panels, measureSpace() || space);
/* -------------------------------- collapse -------------------------------- */
const setCollapsed = (index: number, shut: boolean) => {
const c = currentConstraints();
if (!c[index]?.collapsible || collapsed[index] === shut) return;
const nextCollapsed = collapsed.slice();
nextCollapsed[index] = shut;
const next = sizes.slice();
if (shut) {
restoreRef.current.set(index, sizes[index]);
next[index] = c[index].collapsed;
} else {
const remembered = restoreRef.current.get(index) ?? panels[index].defaultSize ?? c[index].min * 2;
next[index] = clamp(remembered, Math.max(c[index].min, EPS), c[index].max);
}
const fitted = fitLayout(next, nextCollapsed, c, index).map(round);
setLayout({ sizes: fitted, collapsed: nextCollapsed });
onLayout?.(fitted);
};
/** The panel a separator collapses: the one before it if collapsible, otherwise the one after. */
const collapseTarget = (handleIndex: number) =>
panels[handleIndex]?.collapsible ? handleIndex : panels[handleIndex + 1]?.collapsible ? handleIndex + 1 : -1;
/* --------------------------------- pointer --------------------------------- */
const applyToDom = (next: number[], c: Constraint[]) => {
next.forEach((s, i) => {
const el = panelRefs.current[i];
if (el) el.style.flexGrow = String(s);
});
handleRefs.current.forEach((el, i) => {
if (el) {
el.setAttribute("aria-valuenow", String(Math.round(next[i])));
el.toggleAttribute("data-collapsed", c[i].collapsible && next[i] <= c[i].collapsed + EPS);
}
});
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>, index: number) => {
if (event.button !== 0 || event.detail > 1) return;
// Not preventDefault: the native mousedown focuses the separator without a keyboard focus ring.
event.currentTarget.setPointerCapture(event.pointerId);
const s = measureSpace() || space;
dragRef.current = {
handle: index,
start: horizontal ? event.clientX : event.clientY,
base: sizes,
latest: sizes,
cons: constraintsFor(panels, s),
space: s,
};
rootRef.current?.setAttribute("data-dragging", "");
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.space <= 0) return;
const delta = (((horizontal ? event.clientX : event.clientY) - drag.start) / drag.space) * 100;
// Writes straight to the DOM; React state is committed once, on release.
drag.latest = moveHandle(drag.base, drag.handle, delta, drag.cons);
applyToDom(drag.latest, drag.cons);
};
const endDrag = () => {
const drag = dragRef.current;
if (!drag) return;
dragRef.current = null;
rootRef.current?.removeAttribute("data-dragging");
if (drag.latest !== drag.base) commit(drag.latest, drag.cons, drag.base);
};
/* -------------------------------- keyboard -------------------------------- */
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>, index: number) => {
const c = currentConstraints();
const step = keyboardStep * (event.shiftKey ? 4 : 1);
const before = sizes[index];
let delta = 0;
switch (event.key) {
case horizontal ? "ArrowLeft" : "ArrowUp":
delta = -step;
break;
case horizontal ? "ArrowRight" : "ArrowDown":
delta = step;
break;
case "Home":
delta = (c[index].collapsible ? c[index].collapsed : c[index].min) - before;
break;
case "End":
delta = c[index].max - before;
break;
case "Enter": {
const target = collapseTarget(index);
if (target >= 0) {
event.preventDefault();
setCollapsed(target, !collapsed[target]);
}
return;
}
default:
return;
}
event.preventDefault();
const next = moveHandle(sizes, index, delta, c, true);
if (next !== sizes) commit(next, c, sizes);
};
/* --------------------------------- render --------------------------------- */
const duration = reduceMotion ? 0 : collapseDuration;
return (
<div
ref={rootRef}
className={`group/rp flex h-full w-full min-h-0 min-w-0 ${horizontal ? "flex-row" : "flex-col"} data-[dragging]:select-none ${className}`}
style={{ ["--rp-accent" as string]: accent, ["--rp-duration" as string]: `${duration}ms`, ...style }}
{...props}
>
{panels.map((panel, i) => {
const size = sizes[i] ?? 0;
const api: ResizablePanelApi = {
size: round(size),
collapsed: !!collapsed[i],
collapsible: !!panel.collapsible,
collapse: () => setCollapsed(i, true),
expand: () => setCollapsed(i, false),
toggle: () => setCollapsed(i, !collapsed[i]),
};
const last = i === panels.length - 1;
return (
<React.Fragment key={panel.id}>
<div
ref={(node) => {
panelRefs.current[i] = node;
}}
id={panelId(i)}
data-panel={panel.id}
data-collapsed={collapsed[i] || undefined}
className={`relative min-h-0 min-w-0 overflow-hidden transition-[flex-grow] duration-[var(--rp-duration)] ease-[cubic-bezier(0.22,1,0.36,1)] in-data-[dragging]:transition-none ${panel.className ?? ""}`}
style={{ flexGrow: round(size), flexShrink: 1, flexBasis: 0 }}
>
<PanelContext.Provider value={api}>{panel.content}</PanelContext.Provider>
</div>
{!last && (
<div
ref={(node) => {
handleRefs.current[i] = node;
}}
role="separator"
tabIndex={0}
aria-orientation={horizontal ? "vertical" : "horizontal"}
aria-controls={panelId(i)}
aria-label={`Resize ${panel.label ?? panel.id}`}
aria-valuenow={Math.round(size)}
aria-valuemin={ariaLimits(i).min}
aria-valuemax={ariaLimits(i).max}
data-collapsed={(cons[i].collapsible && collapsed[i]) || undefined}
onPointerDown={(event) => onPointerDown(event, i)}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onLostPointerCapture={endDrag}
onDoubleClick={() => {
const target = collapseTarget(i);
if (target >= 0) setCollapsed(target, !collapsed[target]);
}}
onKeyDown={(event) => onKeyDown(event, i)}
className={`group/handle relative z-10 flex flex-none touch-none items-center justify-center outline-hidden ${
horizontal ? "w-px cursor-col-resize flex-col" : "h-px cursor-row-resize flex-row"
} bg-zinc-900/10 dark:bg-white/10`}
>
{/* Generous invisible hit area around the 1px line. */}
<span aria-hidden="true" className={`absolute ${horizontal ? "-inset-x-1.5 inset-y-0" : "-inset-y-1.5 inset-x-0"}`} />
{/* Accent line on hover, focus and drag. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute bg-[var(--rp-accent)] opacity-0 transition-opacity duration-150 group-hover/handle:opacity-100 group-active/handle:opacity-100 ${
horizontal ? "inset-y-0 -left-px w-[3px]" : "inset-x-0 -top-px h-[3px]"
}`}
/>
{handle !== "line" && (
<span
aria-hidden="true"
className={`pointer-events-none relative flex items-center justify-center gap-[3px] rounded-full border border-zinc-900/10 bg-white shadow-[0_1px_2px_rgba(0,0,0,0.06)] transition-colors group-hover/handle:border-[var(--rp-accent)] group-focus-visible/handle:border-[var(--rp-accent)] dark:border-white/15 dark:bg-zinc-900 ${
handle === "grip"
? horizontal
? "h-7 w-2.5 flex-col"
: "h-2.5 w-7 flex-row"
: horizontal
? "h-12 w-1.5"
: "h-1.5 w-12"
}`}
>
{handle === "grip" &&
[0, 1, 2].map((dot) => <span key={dot} className="size-[3px] rounded-full bg-zinc-400 dark:bg-zinc-500" />)}
</span>
)}
{/* Focus ring, drawn around the hit area. */}
<span
aria-hidden="true"
className={`pointer-events-none absolute rounded-sm opacity-0 outline-2 outline-solid outline-[var(--rp-accent)] group-focus-visible/handle:opacity-100 ${
horizontal ? "-inset-x-1 inset-y-1" : "-inset-y-1 inset-x-1"
}`}
/>
</div>
)}
</React.Fragment>
);
})}
</div>
);
}