Resizable Panels
Side-by-side panels with a draggable, keyboard-accessible divider.
- resizable
- layout
- split
- drag
Preview
Installation
The CLI command below is illustrative until the registry endpoint ships. Until then, copy the source from the Code tab into components/fmui/. Every component expects Tailwind CSS v4, the cn helper (clsx + tailwind-merge), and Hugeicons.
$ pnpm dlx shadcn@latest add https://fmui.dev/r/resizable-panels.jsonresizable-panels/resizable-panels.tsx
Usage
example.tsx
import { ResizablePanels } from "@/components/fmui/resizable-panels";
<div className="h-64">
<ResizablePanels left={<Editor />} right={<Preview />} defaultSplit={55} minSplit={25} maxSplit={75} />
</div>Variants
Even split
Default 50/50 layout for side-by-side comparison.
Constrained
Tighten minSplit and maxSplit to keep both panels usable.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| left | ReactNode | - | Content of the left panel. |
| right | ReactNode | - | Content of the right panel. |
| defaultSplit | number | 50 | Initial left width in percent. |
| minSplit | number | 20 | Minimum left width in percent. |
| maxSplit | number | 80 | Maximum left width in percent. |
Accessibility
- The handle is a focusable role=separator with aria-valuenow, min, and max.
- Arrow keys move by 2%, Shift+Arrow by 10%, Home/End snap to the limits, Enter resets.
- Pointer capture keeps dragging smooth even when the cursor leaves the handle.
Source
"use client";
import { useCallback, useRef, useState } from "react";
import { cn } from "@/lib/utils";
export interface ResizablePanelsProps {
left: React.ReactNode;
right: React.ReactNode;
/** Initial left panel width as a percentage. */
defaultSplit?: number;
minSplit?: number;
maxSplit?: number;
className?: string;
}
/**
* Two panels divided by a draggable handle. The handle is a keyboard
* accessible separator: arrow keys nudge it, Home/End snap to the limits,
* and Enter resets to the default split.
*/
export function ResizablePanels({
left,
right,
defaultSplit = 50,
minSplit = 20,
maxSplit = 80,
className,
}: ResizablePanelsProps) {
const [split, setSplit] = useState(defaultSplit);
const [dragging, setDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const clamp = useCallback((v: number) => Math.min(maxSplit, Math.max(minSplit, v)), [minSplit, maxSplit]);
function onPointerDown(e: React.PointerEvent<HTMLDivElement>) {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>) {
if (!dragging || !containerRef.current) return;
const { left: x, width } = containerRef.current.getBoundingClientRect();
setSplit(clamp(((e.clientX - x) / width) * 100));
}
function onPointerUp(e: React.PointerEvent<HTMLDivElement>) {
e.currentTarget.releasePointerCapture(e.pointerId);
setDragging(false);
}
function onKeyDown(e: React.KeyboardEvent) {
const step = e.shiftKey ? 10 : 2;
const map: Record<string, number | undefined> = {
ArrowLeft: clamp(split - step),
ArrowRight: clamp(split + step),
Home: minSplit,
End: maxSplit,
Enter: defaultSplit,
};
const next = map[e.key];
if (next === undefined) return;
e.preventDefault();
setSplit(next);
}
return (
<div
ref={containerRef}
className={cn(
"flex h-full w-full overflow-hidden rounded-[12px] border border-border bg-background",
dragging && "select-none",
className,
)}
style={{ cursor: dragging ? "col-resize" : undefined }}
>
<div className="min-w-0 overflow-auto" style={{ width: `${split}%` }}>
{left}
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize panels"
aria-valuenow={Math.round(split)}
aria-valuemin={minSplit}
aria-valuemax={maxSplit}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onKeyDown={onKeyDown}
onDoubleClick={() => setSplit(defaultSplit)}
className="group relative flex w-2 shrink-0 cursor-col-resize items-center justify-center bg-surface outline-none focus-visible:bg-accent"
>
<span
className={cn(
"h-8 w-1 rounded-full bg-border-strong transition-[background-color,transform] duration-150 group-hover:bg-muted-foreground group-focus-visible:bg-foreground",
dragging && "scale-y-125 bg-foreground",
)}
/>
</div>
<div className="min-w-0 flex-1 overflow-auto">{right}</div>
</div>
);
}
Added Oct 13, 2026. Last updated Oct 23, 2026.