Resizable
Panels split by a hairline that grows an ink grip when you reach for it.
The idea
A handle moves only the boundary it sits on — the panel before it grows by exactly what the one after gives up, each held to its own limits — and sizes are percentages, so the split survives the window resizing. At rest the boundary is a hairline, because a split is structure, not a control asking to be used; point at it, focus it or drag it and a short ink grip grows out of the middle. Every handle is a focusable separator: arrows nudge it, Home and End take it to its limits.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Resizable, ResizableHandle, ResizablePanel } from "mihirmodi-ui";
// Sizes are percent. A handle moves only the boundary it sits on.
<Resizable direction="horizontal" onLayout={(sizes) => save("split", sizes)}>
<ResizablePanel defaultSize={30} minSize={18} maxSize={45}>
<Files />
</ResizablePanel>
<ResizableHandle label="Resize files" />
<ResizablePanel minSize={40}>
{/* Groups nest the other way. */}
<Resizable direction="vertical">
<ResizablePanel defaultSize={65}><Editor /></ResizablePanel>
<ResizableHandle label="Resize terminal" />
<ResizablePanel><Terminal /></ResizablePanel>
</Resizable>
</ResizablePanel>
</Resizable>Props
| Prop | Type | Notes |
|---|---|---|
| direction"horizontal" | "vertical" | "horizontal" | "vertical"= "horizontal" | Groups nest: a panel can hold a group the other way. |
| ResizablePanel defaultSizenumber | number | Percent. Panels without one share what is left. |
| ResizablePanel minSize / maxSizenumber | number | Percent limits the handle respects, by drag and by key. |
| ResizableHandle labelstring | string= "Resize" | The separator's accessible name. |
| onLayout(sizes: number[]) => void | (sizes: number[]) => void | After each drag or key press — persist the reader's split. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useId,
useRef,
useState,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactElement,
type ReactNode,
} from "react";
type Direction = "horizontal" | "vertical";
type Limits = { min: number; max: number };
type GroupState = {
direction: Direction;
sizes: number[];
limits: Limits[];
ids: string[];
/** Move the boundary after panel `i` to make panel `i` this size (percent). */
resize: (i: number, size: number) => void;
begin: (i: number, e: ReactPointerEvent<HTMLElement>) => void;
dragging: number | null;
};
const GroupContext = createContext<GroupState | null>(null);
const SlotContext = createContext<number>(-1);
export type ResizablePanelProps = {
children?: ReactNode;
/** Its starting share of the group, in percent. Panels without one split what is left. */
defaultSize?: number;
/** The smallest it may be dragged to, in percent. */
minSize?: number;
/** The largest, in percent. */
maxSize?: number;
/** A name for the panel; the handle before it is announced as resizing it. */
label?: string;
className?: string;
};
/** One pane of a `Resizable`. Its size is the group's to set. */
export function ResizablePanel({ children, className = "" }: ResizablePanelProps) {
const g = useContext(GroupContext);
const i = useContext(SlotContext);
if (!g || i < 0) return null;
return (
<div
id={g.ids[i]}
data-panel=""
className={`min-h-0 min-w-0 overflow-hidden ${className}`}
style={{ flex: `${g.sizes[i]} 1 0px` }}
>
{children}
</div>
);
}
export type ResizableHandleProps = {
/** The accessible name. Default "Resize". */
label?: string;
className?: string;
};
/**
* The boundary between two panels. A hairline at rest; a short ink grip on
* hover, on focus and while dragged, so it shows itself only when asked.
*/
export function ResizableHandle({ label, className = "" }: ResizableHandleProps) {
const g = useContext(GroupContext);
const i = useContext(SlotContext);
if (!g || i < 0) return null;
const horizontal = g.direction === "horizontal";
const size = g.sizes[i];
const { min, max } = g.limits[i];
const active = g.dragging === i;
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const step = e.shiftKey ? 10 : 2;
const back = horizontal ? "ArrowLeft" : "ArrowUp";
const fwd = horizontal ? "ArrowRight" : "ArrowDown";
let next: number | null = null;
if (e.key === back) next = size - step;
else if (e.key === fwd) next = size + step;
else if (e.key === "Home") next = min;
else if (e.key === "End") next = max;
if (next == null) return;
e.preventDefault();
g.resize(i, next);
};
return (
<div
role="separator"
tabIndex={0}
// The separator's orientation is the line's own: a horizontal group is
// split by a vertical line.
aria-orientation={horizontal ? "vertical" : "horizontal"}
aria-valuenow={Math.round(size)}
aria-valuemin={Math.round(min)}
aria-valuemax={Math.round(max)}
aria-controls={g.ids[i]}
aria-label={label ?? "Resize"}
onPointerDown={(e) => g.begin(i, e)}
onKeyDown={onKey}
data-active={active || undefined}
className={`group relative z-10 flex shrink-0 touch-none select-none items-center justify-center ${
horizontal ? "w-px cursor-col-resize" : "h-px cursor-row-resize"
} ${className}`}
>
{/* The line itself, and a hit area twelve pixels wide around it. */}
<span
aria-hidden
className={`absolute bg-gray-alpha-400 ${horizontal ? "inset-y-0 left-0 w-px" : "inset-x-0 top-0 h-px"}`}
/>
<span aria-hidden className={`absolute ${horizontal ? "inset-y-0 -left-1.5 -right-1.5" : "inset-x-0 -bottom-1.5 -top-1.5"}`} />
<span
aria-hidden
className={`relative rounded-full bg-ink opacity-0 transition-[opacity,transform] duration-150 ease-geist group-hover:opacity-100 group-focus-visible:opacity-100 group-data-[active]:opacity-100 motion-reduce:transition-opacity ${
horizontal
? "h-8 w-[3px] scale-y-50 group-hover:scale-y-100 group-focus-visible:scale-y-100 group-data-[active]:scale-y-100"
: "h-[3px] w-8 scale-x-50 group-hover:scale-x-100 group-focus-visible:scale-x-100 group-data-[active]:scale-x-100"
}`}
/>
</div>
);
}
export type ResizableProps = {
/** `ResizablePanel`s with a `ResizableHandle` between each pair. */
children: ReactNode;
direction?: Direction;
/** Every settled layout, in percent — persist it to restore the reader's split. */
onLayout?: (sizes: number[]) => void;
className?: string;
};
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
/**
* Two or more panels split by handles you can drag.
*
* A handle moves only the boundary it sits on: the panel before it grows by
* exactly what the panel after it gives up, each held to its own minimum and
* maximum, and nothing further along the row shifts. Sizes are percentages
* and the panels are flex items weighted by them, so the split survives the
* window being resized and nothing has to be measured until a drag begins.
*
* At rest the boundary is a hairline — the split is structure, not a control
* shouting to be used. Point at it, focus it or drag it and a short ink grip
* grows out of the line's middle, which says "this moves" exactly when that is
* the question. Every handle is a focusable `separator` carrying the size of
* the panel before it, so the arrows nudge it (Shift for bigger steps), Home
* and End take it to its limits, and a screen reader hears the percentage.
*
* <Resizable direction="horizontal">
* <ResizablePanel defaultSize={30} minSize={20}>Files</ResizablePanel>
* <ResizableHandle />
* <ResizablePanel>Editor</ResizablePanel>
* </Resizable>
*/
export function Resizable({ children, direction = "horizontal", onLayout, className = "" }: ResizableProps) {
const base = useId();
const root = useRef<HTMLDivElement>(null);
const items = Children.toArray(children).filter(isValidElement) as ReactElement[];
const panels = items.filter((c) => c.type === ResizablePanel) as ReactElement<ResizablePanelProps>[];
const ids = panels.map((_, i) => `${base}-panel-${i}`);
const limits: Limits[] = panels.map((p) => ({ min: p.props.minSize ?? 0, max: p.props.maxSize ?? 100 }));
const [sizes, setSizes] = useState<number[]>(() => {
const given = panels.map((p) => p.props.defaultSize);
const taken = given.reduce<number>((a, s) => a + (s ?? 0), 0);
const open = given.filter((s) => s == null).length;
const share = open ? Math.max(0, 100 - taken) / open : 0;
return given.map((s) => s ?? share);
});
const [dragging, setDragging] = useState<number | null>(null);
const latest = useRef(sizes);
latest.current = sizes;
// Limits are rebuilt from props every render; the callback reads the latest.
const bounds = useRef(limits);
bounds.current = limits;
const resize = useCallback((i: number, want: number) => {
setSizes((prev) => {
const a = prev[i];
const b = prev[i + 1];
if (a == null || b == null) return prev;
const pair = a + b;
const la = bounds.current[i];
const lb = bounds.current[i + 1];
// Both panels' limits bound the one boundary: A in its own range, and
// B — which is whatever A leaves — in its.
const lo = Math.max(la.min, pair - lb.max);
const hi = Math.min(la.max, pair - lb.min);
const nextA = clamp(want, lo, hi);
if (Math.abs(nextA - a) < 0.01) return prev;
const next = [...prev];
next[i] = nextA;
next[i + 1] = pair - nextA;
return next;
});
}, []);
const begin = (i: number, e: ReactPointerEvent<HTMLElement>) => {
const el = root.current;
if (!el || e.button !== 0) return;
e.preventDefault();
const handle = e.currentTarget;
handle.focus({ preventScroll: true });
handle.setPointerCapture(e.pointerId);
const box = el.getBoundingClientRect();
const span = direction === "horizontal" ? box.width : box.height;
const from = direction === "horizontal" ? e.clientX : e.clientY;
const start = latest.current[i];
setDragging(i);
const prevCursor = document.body.style.cursor;
document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize";
const move = (ev: PointerEvent) => {
const d = ((direction === "horizontal" ? ev.clientX : ev.clientY) - from) / Math.max(1, span);
resize(i, start + d * 100);
};
const up = () => {
setDragging(null);
document.body.style.cursor = prevCursor;
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", up);
handle.removeEventListener("pointercancel", up);
onLayout?.(latest.current);
};
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", up);
handle.addEventListener("pointercancel", up);
};
// Keyboard resizes settle on each press, so report them as they land.
const resizeAndReport = (i: number, want: number) => {
resize(i, want);
if (onLayout) requestAnimationFrame(() => onLayout(latest.current));
};
let panel = -1;
return (
<GroupContext.Provider value={{ direction, sizes, limits, ids, resize: resizeAndReport, begin, dragging }}>
<div
ref={root}
data-direction={direction}
className={`flex h-full w-full ${direction === "horizontal" ? "flex-row" : "flex-col"} ${className}`}
>
{items.map((c, n) => {
if (c.type === ResizablePanel) panel += 1;
// A handle belongs to the panel before it.
return (
<SlotContext.Provider key={c.key ?? n} value={panel}>
{c}
</SlotContext.Provider>
);
})}
</div>
</GroupContext.Provider>
);
}