Resizable
Panel groups with draggable, keyboard accessible handles. Horizontal, vertical and nested layouts.
NewFiles
- app/layout.tsx
- app/page.tsx
- lib/db.ts
Editor
1'use client';23import { Resizable } from '@/components/ui/resizable';45export function Default() {6 return (7 <Resizable className="border-border bg-card h-48 w-full max-w-md rounded-lg border">8 <Resizable.Panel defaultSize={35} minSize={20}>9 <div className="flex h-full flex-col gap-2 p-4">10 <span className="text-sm font-medium">Files</span>11 <ul className="text-muted-foreground flex flex-col gap-1 font-mono text-xs">12 <li>app/layout.tsx</li>13 <li>app/page.tsx</li>14 <li>lib/db.ts</li>15 </ul>16 </div>17 </Resizable.Panel>18 <Resizable.Handle withHandle />19 <Resizable.Panel minSize={30}>20 <div className="flex h-full items-center justify-center p-4">21 <span className="text-muted-foreground text-sm">Editor</span>22 </div>23 </Resizable.Panel>24 </Resizable>25 );26}
Installation
Copy and paste the following code into your project.
1'use client';23import * as React from 'react';4import { cn } from '../lib/cn';56type ResizableDirection = 'horizontal' | 'vertical';78interface PanelConstraints {9 defaultSize?: number;10 minSize: number;11 maxSize: number;12}1314interface ResizableContextValue {15 direction: ResizableDirection;16 sizes: number[];17 constraints: PanelConstraints[];18 panelIds: string[];19 containerRef: React.RefObject<HTMLDivElement | null>;20 resizeAt: (handleIndex: number, delta: number) => void;21 resizeTo: (handleIndex: number, target: number) => void;22 setDragging: (dragging: boolean) => void;23}2425const ResizableContext = React.createContext<ResizableContextValue | null>(null);26const PanelIndexContext = React.createContext<number>(-1);27const HandleIndexContext = React.createContext<number>(-1);2829function useResizableContext(): ResizableContextValue {30 const context = React.use(ResizableContext);31 if (!context) throw new Error('Resizable parts must be used within Resizable');32 return context;33}3435function clamp(value: number, min: number, max: number): number {36 return Math.min(Math.max(value, min), max);37}3839function initialSizes(constraints: PanelConstraints[]): number[] {40 const assigned = constraints.map((panel) => panel.defaultSize);41 const used = assigned.reduce<number>((total, size) => total + (size ?? 0), 0);42 const missing = assigned.filter((size) => size === undefined).length;43 const remainder = Math.max(100 - used, 0);44 const share = missing > 0 ? remainder / missing : 0;4546 const sizes = assigned.map((size, index) => {47 const panel = constraints[index];48 const proposed = size ?? share;49 return panel ? clamp(proposed, panel.minSize, panel.maxSize) : proposed;50 });5152 const total = sizes.reduce((sum, size) => sum + size, 0);53 return total > 0 ? sizes.map((size) => (size / total) * 100) : sizes;54}5556const ID_SEPARATOR = '\u001f';5758function parseConstraints(key: string): PanelConstraints[] {59 if (!key) return [];60 return key.split('|').map((entry) => {61 const [defaultSize = '', minSize = '10', maxSize = '100'] = entry.split(':');62 return {63 defaultSize: defaultSize === '' ? undefined : Number(defaultSize),64 minSize: Number(minSize),65 maxSize: Number(maxSize),66 };67 });68}6970interface ResizableProps extends React.HTMLAttributes<HTMLDivElement> {71 direction?: ResizableDirection;72 onLayout?: (sizes: number[]) => void;73}7475interface ResizablePanelProps extends React.HTMLAttributes<HTMLDivElement> {76 defaultSize?: number;77 minSize?: number;78 maxSize?: number;79}8081const ResizableRoot = ({82 direction = 'horizontal',83 onLayout,84 className,85 children,86 ref,87 ...props88}: ResizableProps & { ref?: React.Ref<HTMLDivElement> }) => {89 const containerRef = React.useRef<HTMLDivElement>(null);90 const [dragging, setDragging] = React.useState(false);91 const baseId = React.useId();9293 const childArray = React.Children.toArray(children);94 const isPanel = (child: React.ReactNode): child is React.ReactElement<ResizablePanelProps> =>95 React.isValidElement(child) && child.type === ResizablePanel;96 const panelPositions = childArray.flatMap((child, position) =>97 isPanel(child) ? [position] : [],98 );99 const panels = childArray.filter(isPanel);100 const items = childArray.map((child, position) => {101 if (!React.isValidElement(child)) return child;102103 if (child.type === ResizablePanel) {104 return (105 <PanelIndexContext key={child.key} value={panelPositions.indexOf(position)}>106 {child}107 </PanelIndexContext>108 );109 }110111 if (child.type === ResizableHandle) {112 const panelsBefore = panelPositions.filter((panel) => panel < position).length;113 return (114 <HandleIndexContext key={child.key} value={panelsBefore - 1}>115 {child}116 </HandleIndexContext>117 );118 }119120 return child;121 });122123 const constraintsKey = panels124 .map((panel) =>125 [panel.props.defaultSize ?? '', panel.props.minSize ?? 10, panel.props.maxSize ?? 100].join(126 ':',127 ),128 )129 .join('|');130 const panelIdsKey = panels131 .map((panel, index) => panel.props.id ?? `${baseId}-panel-${index}`)132 .join(ID_SEPARATOR);133 const constraints = React.useMemo(() => parseConstraints(constraintsKey), [constraintsKey]);134 const panelIds = React.useMemo(135 () => (panelIdsKey ? panelIdsKey.split(ID_SEPARATOR) : []),136 [panelIdsKey],137 );138139 const fallbackSizes = React.useMemo(140 () => initialSizes(parseConstraints(constraintsKey)),141 [constraintsKey],142 );143 const [sizes, setSizes] = React.useState<number[]>(fallbackSizes);144 const layout = sizes.length === fallbackSizes.length ? sizes : fallbackSizes;145146 const onLayoutRef = React.useRef(onLayout);147 const constraintsRef = React.useRef(constraints);148149 React.useLayoutEffect(() => {150 onLayoutRef.current = onLayout;151 constraintsRef.current = constraints;152 });153154 const resizeBy = React.useCallback(155 (handleIndex: number, getDelta: (before: number) => number) => {156 setSizes((previous) => {157 const current =158 previous.length === constraintsRef.current.length159 ? previous160 : initialSizes(constraintsRef.current);161 const before = current[handleIndex];162 const after = current[handleIndex + 1];163 const beforeRule = constraintsRef.current[handleIndex];164 const afterRule = constraintsRef.current[handleIndex + 1];165 if (before === undefined || after === undefined || !beforeRule || !afterRule) {166 return previous;167 }168169 const maxGrow = Math.min(beforeRule.maxSize - before, after - afterRule.minSize);170 const maxShrink = Math.min(before - beforeRule.minSize, afterRule.maxSize - after);171 const applied = clamp(getDelta(before), -maxShrink, maxGrow);172 if (applied === 0) return previous;173174 const next = [...current];175 next[handleIndex] = before + applied;176 next[handleIndex + 1] = after - applied;177 return next;178 });179 },180 [],181 );182183 const resizeAt = React.useCallback(184 (handleIndex: number, delta: number) => resizeBy(handleIndex, () => delta),185 [resizeBy],186 );187188 const resizeTo = React.useCallback(189 (handleIndex: number, target: number) => resizeBy(handleIndex, (before) => target - before),190 [resizeBy],191 );192193 const initialSizesRef = React.useRef(sizes);194 React.useEffect(() => {195 if (sizes === initialSizesRef.current) return;196 onLayoutRef.current?.(sizes);197 }, [sizes]);198199 const context = React.useMemo(200 () => ({201 direction,202 sizes: layout,203 constraints,204 panelIds,205 containerRef,206 resizeAt,207 resizeTo,208 setDragging,209 }),210 [direction, layout, constraints, panelIds, resizeAt, resizeTo],211 );212213 return (214 <ResizableContext value={context}>215 <div216 ref={(node) => {217 containerRef.current = node;218 if (typeof ref === 'function') ref(node);219 else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;220 }}221 data-slot="resizable"222 data-direction={direction}223 data-dragging={dragging ? '' : undefined}224 className={cn(225 'flex size-full data-[direction=vertical]:flex-col data-[dragging]:select-none',226 className,227 )}228 {...props}229 >230 {items}231 </div>232 </ResizableContext>233 );234};235ResizableRoot.displayName = 'Resizable';236237const ResizablePanel = ({238 defaultSize: _defaultSize,239 minSize: _minSize,240 maxSize: _maxSize,241 className,242 style,243 id,244 ref,245 ...props246}: ResizablePanelProps & { ref?: React.Ref<HTMLDivElement> }) => {247 const { sizes, direction, panelIds } = useResizableContext();248 const index = React.use(PanelIndexContext);249 const size = sizes[index] ?? 0;250251 return (252 <div253 ref={ref}254 id={id ?? panelIds[index]}255 data-slot="resizable-panel"256 data-direction={direction}257 style={{ flex: `${size} 1 0px`, ...style }}258 className={cn('min-h-0 min-w-0 overflow-hidden', className)}259 {...props}260 />261 );262};263ResizablePanel.displayName = 'ResizablePanel';264265interface ResizableHandleProps extends React.HTMLAttributes<HTMLDivElement> {266 withHandle?: boolean;267 disabled?: boolean;268 step?: number;269 label?: string;270}271272const ResizableHandle = ({273 withHandle = false,274 disabled = false,275 step = 2,276 label = 'Resize panels',277 className,278 onPointerDown,279 onPointerMove,280 onPointerUp,281 onKeyDown,282 ref,283 ...props284}: ResizableHandleProps & { ref?: React.Ref<HTMLDivElement> }) => {285 const { direction, sizes, constraints, panelIds, containerRef, resizeAt, resizeTo, setDragging } =286 useResizableContext();287 const index = React.use(HandleIndexContext);288 const dragRef = React.useRef<{289 start: number;290 before: number;291 total: number;292 pointer: number;293 } | null>(null);294 const frameRef = React.useRef(0);295 const [active, setActive] = React.useState(false);296 const isHorizontal = direction === 'horizontal';297298 const before = sizes[index] ?? 0;299 const rule = constraints[index];300301 const containerSize = () => {302 const container = containerRef.current;303 if (!container) return 0;304 return isHorizontal ? container.clientWidth : container.clientHeight;305 };306307 React.useEffect(308 () => () => {309 if (frameRef.current) cancelAnimationFrame(frameRef.current);310 },311 [],312 );313314 const applyDrag = () => {315 frameRef.current = 0;316 const drag = dragRef.current;317 if (!drag || drag.total === 0) return;318 const deltaPercent = ((drag.pointer - drag.start) / drag.total) * 100;319 resizeTo(index, drag.before + deltaPercent);320 };321322 const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {323 onPointerDown?.(event);324 if (disabled || event.defaultPrevented || event.button !== 0) return;325 event.preventDefault();326 event.currentTarget.setPointerCapture(event.pointerId);327 const start = isHorizontal ? event.clientX : event.clientY;328 dragRef.current = { start, before, total: containerSize(), pointer: start };329 setDragging(true);330 setActive(true);331 };332333 const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {334 onPointerMove?.(event);335 const drag = dragRef.current;336 if (!drag) return;337 drag.pointer = isHorizontal ? event.clientX : event.clientY;338 if (!frameRef.current) frameRef.current = requestAnimationFrame(applyDrag);339 };340341 const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {342 onPointerUp?.(event);343 if (!dragRef.current) return;344 if (frameRef.current) {345 cancelAnimationFrame(frameRef.current);346 applyDrag();347 }348 dragRef.current = null;349 setDragging(false);350 setActive(false);351 if (event.currentTarget.hasPointerCapture(event.pointerId)) {352 event.currentTarget.releasePointerCapture(event.pointerId);353 }354 };355356 const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {357 onKeyDown?.(event);358 if (disabled || event.defaultPrevented) return;359 const grow = isHorizontal ? 'ArrowRight' : 'ArrowDown';360 const shrink = isHorizontal ? 'ArrowLeft' : 'ArrowUp';361 const amount = event.shiftKey ? step * 5 : step;362363 if (event.key === grow) {364 event.preventDefault();365 resizeAt(index, amount);366 } else if (event.key === shrink) {367 event.preventDefault();368 resizeAt(index, -amount);369 } else if (event.key === 'Home') {370 event.preventDefault();371 resizeAt(index, -100);372 } else if (event.key === 'End') {373 event.preventDefault();374 resizeAt(index, 100);375 }376 };377378 return (379 <div380 ref={ref}381 role="separator"382 tabIndex={disabled ? -1 : 0}383 aria-label={label}384 aria-controls={panelIds[index]}385 aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}386 aria-valuenow={Math.round(before)}387 aria-valuemin={rule ? Math.round(rule.minSize) : 0}388 aria-valuemax={rule ? Math.round(rule.maxSize) : 100}389 aria-disabled={disabled || undefined}390 data-slot="resizable-handle"391 data-direction={direction}392 data-disabled={disabled ? '' : undefined}393 data-active={active ? '' : undefined}394 onPointerDown={handlePointerDown}395 onPointerMove={handlePointerMove}396 onPointerUp={handlePointerUp}397 onPointerCancel={handlePointerUp}398 onKeyDown={handleKeyDown}399 className={cn(400 'group/handle bg-border focus-visible:ring-ring ring-offset-background relative flex shrink-0 touch-none items-center justify-center outline-none focus-visible:ring-2 focus-visible:ring-offset-1 data-[disabled]:pointer-events-none',401 isHorizontal402 ? 'w-px cursor-col-resize after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 pointer-coarse:after:w-6'403 : 'h-px w-full cursor-row-resize after:absolute after:inset-x-0 after:top-1/2 after:h-2 after:-translate-y-1/2 pointer-coarse:after:h-6',404 className,405 )}406 {...props}407 >408 {withHandle && (409 <span410 aria-hidden="true"411 className={cn(412 'bg-border-interactive/70 group-hover/handle:bg-foreground/85 group-data-[active]/handle:bg-foreground/85 group-focus-visible/handle:bg-ring z-10 shrink-0 rounded-full transition-[width,height,background-color] duration-200 motion-reduce:transition-none',413 isHorizontal414 ? 'h-7 w-[3px] group-hover/handle:h-11 group-data-[active]/handle:h-11'415 : 'h-[3px] w-7 group-hover/handle:w-11 group-data-[active]/handle:w-11',416 )}417 />418 )}419 </div>420 );421};422ResizableHandle.displayName = 'ResizableHandle';423424const Resizable = Object.assign(ResizableRoot, {425 Panel: ResizablePanel,426 Handle: ResizableHandle,427});428429export { Resizable, useResizableContext };430export type { ResizableProps, ResizablePanelProps, ResizableHandleProps, ResizableDirection };
Update the import paths to match your project structure.
Anatomy
1import { Resizable } from '@/components/ui/resizable';
1<Resizable direction="horizontal">2 <Resizable.Panel defaultSize={30} minSize={20}>3 Sidebar4 </Resizable.Panel>5 <Resizable.Handle withHandle />6 <Resizable.Panel>Content</Resizable.Panel>7</Resizable>
Sizes are percentages of the group. Panels without
defaultSize share whatever is left. Dragging a handle moves the boundary between the two panels next to it and never lets either one go past its minSize or maxSize. The handle is a separator named Resize panels by default, with aria-valuenow and aria-controls pointing at the panel before it. It is focusable, and moves with the arrow keys, Home and End. Hold Shift for bigger steps.Composition
Resizableโโโ Resizable.Panelโโโ Resizable.Handleโโโ Resizable.Panel
Panels and handles must be direct children of the group so it can number them. Nest another
Resizable inside a panel for grids.Variants
Default
Two panels side by side with a visible grip on the handle.
Files
- app/layout.tsx
- app/page.tsx
- lib/db.ts
Editor
1'use client';23import { Resizable } from '@/components/ui/resizable';45export function Default() {6 return (7 <Resizable className="border-border bg-card h-48 w-full max-w-md rounded-lg border">8 <Resizable.Panel defaultSize={35} minSize={20}>9 <div className="flex h-full flex-col gap-2 p-4">10 <span className="text-sm font-medium">Files</span>11 <ul className="text-muted-foreground flex flex-col gap-1 font-mono text-xs">12 <li>app/layout.tsx</li>13 <li>app/page.tsx</li>14 <li>lib/db.ts</li>15 </ul>16 </div>17 </Resizable.Panel>18 <Resizable.Handle withHandle />19 <Resizable.Panel minSize={30}>20 <div className="flex h-full items-center justify-center p-4">21 <span className="text-muted-foreground text-sm">Editor</span>22 </div>23 </Resizable.Panel>24 </Resizable>25 );26}
Vertical
direction="vertical" stacks the panels and the handle becomes a horizontal bar.Preview
$ pnpm build
Compiled 42 routes in 3.1s
Ready on http://localhost:3000
1'use client';23import { Resizable } from '@/components/ui/resizable';45export function Vertical() {6 return (7 <Resizable8 direction="vertical"9 className="border-border bg-card h-64 w-full max-w-md rounded-lg border"10 >11 <Resizable.Panel defaultSize={60} minSize={25}>12 <div className="flex h-full items-center justify-center p-4">13 <span className="text-sm font-medium">Preview</span>14 </div>15 </Resizable.Panel>16 <Resizable.Handle withHandle />17 <Resizable.Panel minSize={20}>18 <div className="text-muted-foreground h-full overflow-auto p-4 font-mono text-xs leading-relaxed">19 <p>$ pnpm build</p>20 <p>Compiled 42 routes in 3.1s</p>21 <p>Ready on http://localhost:3000</p>22 </div>23 </Resizable.Panel>24 </Resizable>25 );26}
Nested
A vertical group inside a panel of a horizontal group.
Sidebar
Editor
Terminal
1'use client';23import { Resizable } from '@/components/ui/resizable';45export function Nested() {6 return (7 <Resizable className="border-border bg-card h-64 w-full max-w-md rounded-lg border">8 <Resizable.Panel defaultSize={28} minSize={15}>9 <div className="flex h-full items-center justify-center p-4">10 <span className="text-muted-foreground text-sm">Sidebar</span>11 </div>12 </Resizable.Panel>13 <Resizable.Handle />14 <Resizable.Panel minSize={30}>15 <Resizable direction="vertical">16 <Resizable.Panel defaultSize={65} minSize={30}>17 <div className="flex h-full items-center justify-center p-4">18 <span className="text-sm font-medium">Editor</span>19 </div>20 </Resizable.Panel>21 <Resizable.Handle />22 <Resizable.Panel minSize={15}>23 <div className="flex h-full items-center justify-center p-4">24 <span className="text-muted-foreground text-sm">Terminal</span>25 </div>26 </Resizable.Panel>27 </Resizable>28 </Resizable.Panel>29 </Resizable>30 );31}
API Reference
Resizable
| Prop | Type | Default | Description |
|---|---|---|---|
direction | "horizontal" | "vertical" | "horizontal" | Axis of the layout |
onLayout | (sizes: number[]) => void | - | Called with every panel size after a resize |
className | string | - | Classes for the group. Give it a height when vertical |
Resizable.Panel
| Prop | Type | Default | Description |
|---|---|---|---|
defaultSize | number | - | Initial size in percent |
minSize | number | 10 | Smallest size in percent |
maxSize | number | 100 | Largest size in percent |
className | string | - | Additional CSS classes. Content is clipped |
Resizable.Handle
| Prop | Type | Default | Description |
|---|---|---|---|
withHandle | boolean | false | Show a small pill in the middle that hints the edge can be dragged |
disabled | boolean | false | Ignore pointer and keyboard |
step | number | 2 | Percent moved per arrow key press |
label | string | "Resize panels" | Accessible name of the handle |
className | string | - | Additional CSS classes |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.