Resizable
Grupos de paneles con manijas arrastrables y accesibles por teclado. Layouts horizontales, verticales y anidados.
NuevoFiles
- 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}
Instalación
Copia y pega el siguiente código en tu proyecto.
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 };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Anatomía
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>Contenido</Resizable.Panel>7</Resizable>
Los tamaños son porcentajes del grupo. Los paneles sin
defaultSize se reparten lo que queda. Arrastrar una manija mueve el límite entre los dos paneles vecinos y nunca deja que alguno pase su minSize o maxSize. La manija es un separator que se llama Resize panels por defecto, con aria-valuenow y aria-controls apuntando al panel anterior. Recibe foco y se mueve con las flechas, Home y End. Con Shift los pasos son más grandes.Composición
Resizable├── Resizable.Panel├── Resizable.Handle└── Resizable.Panel
Paneles y manijas deben ser hijos directos del grupo para que pueda numerarlos. Anida otro
Resizable dentro de un panel para armar grillas.Variantes
Predeterminado (Default)
Dos paneles lado a lado con un agarre visible en la manija.
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" apila los paneles y la manija pasa a ser una barra horizontal.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}
Anidado
Un grupo vertical dentro de un panel de un grupo horizontal.
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}
Referencia de API
Resizable
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
direction | "horizontal" | "vertical" | "horizontal" | Eje del layout |
onLayout | (sizes: number[]) => void | - | Se llama con todos los tamaños después de cada cambio |
className | string | - | Clases para el grupo. Dale una altura si es vertical |
Resizable.Panel
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
defaultSize | number | - | Tamaño inicial en porcentaje |
minSize | number | 10 | Tamaño mínimo en porcentaje |
maxSize | number | 100 | Tamaño máximo en porcentaje |
className | string | - | Clases CSS adicionales. El contenido se recorta |
Resizable.Handle
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
withHandle | boolean | false | Muestra una pequeña píldora en el medio que indica que se puede arrastrar |
disabled | boolean | false | Ignora puntero y teclado |
step | number | 2 | Porcentaje que mueve cada pulsación de flecha |
label | string | "Resize panels" | Nombre accesible de la manija |
className | string | - | Clases CSS adicionales |
¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.