Resizable Panels
Resizable Panels for a useful, keyboard-friendly product interface.
Make it yours.
Install the editable source and its dependencies into your React + Tailwind project.
Scroll horizontally for long lines
pnpm dlx shadcn@4.0.8 add http://localhost:3000/r/resizable-panels.jsonUse it
Scroll horizontally for long lines
"use client";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/jez-ui/ui/resizable-panels";
export default function Example() {
return (
<>
<div className="w-full max-w-2xl">
<div className="mb-7">
<h3 className="text-2xl tracking-tight">Space that works for you.</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Adjust the balance with a pointer or the arrow keys.
</p>
</div>
<ResizablePanelGroup>
<ResizablePanel
defaultSize={25}
minSize={15}
>
Navigation
</ResizablePanel>
<ResizableHandle aria-label="Resize navigation" />
<ResizablePanel
defaultSize={50}
minSize={20}
>
Your workspace. Drag a divider or focus it and use arrow keys.
</ResizablePanel>
<ResizableHandle aria-label="Resize inspector" />
<ResizablePanel
defaultSize={25}
minSize={15}
>
Inspector
</ResizablePanel>
</ResizablePanelGroup>
</div>
</>
);
}
Compose your own
These styled parts are included in the same install. Use children to build your layout; add className only when you want an override.
Scroll horizontally for long lines
import { ResizablePanels, ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/jez-ui/ui/resizable-panels";Props & types
Scroll horizontally for long lines
{
left: React.ReactNode;
right: React.ReactNode;
className?: string;
}Source
View 2 source files
registry/ui/resizable-panels.tsx
Scroll horizontally for long lines
"use client";
import * as React from "react";
import { cn } from "./utils";
type ResizeState = {
sizes: number[];
resize: (index: number, delta: number) => void;
direction: "horizontal" | "vertical";
ref: React.RefObject<HTMLDivElement | null>;
mins: number[];
};
const Context = React.createContext<ResizeState | null>(null);
function useResize() {
const state = React.useContext(Context);
if (!state) throw new Error("ResizablePanel requires ResizablePanelGroup");
return state;
}
export function ResizablePanelGroup({
children,
direction = "horizontal",
className,
...props
}: React.ComponentProps<"div"> & { direction?: "horizontal" | "vertical" }) {
const ref = React.useRef<HTMLDivElement>(null);
const childrenArray = React.Children.toArray(children);
const panels = childrenArray.filter(
(c): c is React.ReactElement<React.ComponentProps<typeof ResizablePanel>> =>
React.isValidElement(c) && c.type === ResizablePanel,
);
const count = panels.length;
const initial = panels.map(
(p) => p.props.defaultSize ?? 100 / Math.max(count, 1),
),
sum = initial.reduce((a, b) => a + b, 0) || 1;
const [stored, setSizes] = React.useState(() =>
initial.map((v) => (v / sum) * 100),
);
const sizes =
stored.length === count ? stored : initial.map((v) => (v / sum) * 100);
const mins = panels.map((p) => p.props.minSize ?? 10);
const resize = (index: number, delta: number) =>
setSizes((prev) => {
const current = prev.length === count ? prev : sizes;
if (index < 0 || index >= count - 1) return current;
const change = Math.max(
mins[index] - current[index],
Math.min(delta, current[index + 1] - mins[index + 1]),
);
return current.map((v, i) =>
i === index ? v + change : i === index + 1 ? v - change : v,
);
});
let panelIndex = 0;
return (
<Context.Provider value={{ sizes, resize, direction, ref, mins }}>
<div
ref={ref}
className={cn(
"flex min-h-48 min-w-0 overflow-hidden rounded-xl border border-border",
direction === "vertical" && "h-96 flex-col",
className,
)}
{...props}
>
{childrenArray.map((child, i) => {
if (!React.isValidElement(child)) return child;
if (child.type === ResizablePanel)
return React.cloneElement(
child as React.ReactElement<{ index: number }>,
{ index: panelIndex++, key: child.key ?? i },
);
if (child.type === ResizableHandle)
return React.cloneElement(
child as React.ReactElement<{ index: number }>,
{ index: panelIndex - 1, key: child.key ?? i },
);
return child;
})}
</div>
</Context.Provider>
);
}
export function ResizablePanel({
index = 0,
defaultSize: _defaultSize,
minSize: _minSize,
className,
style,
...props
}: React.ComponentProps<"div"> & {
defaultSize?: number;
minSize?: number;
index?: number;
}) {
const { sizes } = useResize();
return (
<div
className={cn(
"min-h-0 min-w-0 overflow-auto p-4 text-sm leading-relaxed",
className,
)}
style={{
flexBasis: `${sizes[index]}%`,
flexGrow: 0,
flexShrink: 1,
...style,
}}
{...props}
/>
);
}
export function ResizableHandle({
index = 0,
className,
onKeyDown,
...props
}: React.ComponentProps<"div"> & { index?: number }) {
const { sizes, resize, direction, ref, mins } = useResize();
const previous = React.useRef<number | null>(null),
vertical = direction === "vertical";
return (
<div
role="separator"
tabIndex={0}
aria-label="Resize panels"
aria-orientation={vertical ? "horizontal" : "vertical"}
aria-valuenow={Math.round(sizes[index] ?? 0)}
aria-valuemin={mins[index]}
aria-valuemax={Math.round(
(sizes[index] ?? 0) + (sizes[index + 1] ?? 0) - (mins[index + 1] ?? 0),
)}
className={cn(
"relative shrink-0 touch-none bg-border hover:bg-primary focus-visible:bg-primary",
vertical
? "h-px cursor-row-resize after:absolute after:inset-x-0 after:-top-2 after:h-4"
: "w-px cursor-col-resize after:absolute after:inset-y-0 after:-left-2 after:w-4",
className,
)}
onKeyDown={(e) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
const before = vertical ? "ArrowUp" : "ArrowLeft",
after = vertical ? "ArrowDown" : "ArrowRight";
if (e.key === before || e.key === after) {
e.preventDefault();
resize(index, e.key === before ? -5 : 5);
}
}}
onPointerDown={(e) => {
previous.current = vertical ? e.clientY : e.clientX;
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
if (
!e.currentTarget.hasPointerCapture(e.pointerId) ||
previous.current === null ||
!ref.current
)
return;
const next = vertical ? e.clientY : e.clientX,
box = ref.current.getBoundingClientRect();
resize(
index,
((next - previous.current) / (vertical ? box.height : box.width)) *
100,
);
previous.current = next;
}}
onPointerUp={(e) => {
previous.current = null;
e.currentTarget.releasePointerCapture(e.pointerId);
}}
onPointerCancel={() => {
previous.current = null;
}}
{...props}
/>
);
}
export function ResizablePanels({
left,
right,
className,
}: {
left: React.ReactNode;
right: React.ReactNode;
className?: string;
}) {
return (
<ResizablePanelGroup className={className}>
<ResizablePanel defaultSize={40} minSize={20}>
{left}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel defaultSize={60} minSize={20}>
{right}
</ResizablePanel>
</ResizablePanelGroup>
);
}
registry/ui/utils.ts
Scroll horizontally for long lines
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...values: ClassValue[]) {
return twMerge(clsx(values));
}
Dependencies
clsx@2.1.1 · tailwind-merge@3.5.0
Accessibility & behaviour
Provide meaningful labels and preserve keyboard focus styles. Check contrast when customising theme colours.
Read the accessibility and performance guide →