Resizable
Drag a handle to grow or shrink adjacent panes. Split views and customizable workspaces.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Split workspace:
```tsx import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from '@/components/atoms/Resizable';
<ResizablePanelGroup orientation="horizontal" panels={2}> <ResizablePanel index={0}>Left</ResizablePanel> <ResizableHandle index={0} /> <ResizablePanel index={1}>Right</ResizablePanel> </ResizablePanelGroup> ```
## Accesibilidad
- Keyboard support lives on the handle; keep a visible affordance (`withHandle` when needed). - Give the group an explicit height/width or flex grow — zero-size groups cannot be resized meaningfully.
## Cuándo no usar
- Fixed two-column marketing layouts → CSS grid, not draggable panes. - Mobile single-column flows — prefer stacked sections over split handles.
## Criterio de uso
- Usa Resizable cuando el usuario necesita ajustar la relación entre panes durante una tarea, como editor y preview. - Define un tamaño inicial razonable y límites que mantengan cada panel útil; dos paneles iguales no siempre son la mejor distribución. - En pantallas estrechas, cambia a layout apilado en lugar de conservar handles difíciles de manipular.
## Gotchas
- El grupo necesita tamaño explícito o `flex-grow`; un contenedor sin alto o ancho no puede redimensionarse. - Los índices de panel y handle deben ser contiguos y pertenecer al mismo `ResizablePanelGroup`.
Uso
import {
ResizablePanelGroup, ResizablePanel, ResizableHandle,
} from '@/components/atoms/Resizable';
<ResizablePanelGroup orientation="horizontal" panels={2}>
<ResizablePanel index={0}>Left</ResizablePanel>
<ResizableHandle />
<ResizablePanel index={1}>Right</ResizablePanel>
</ResizablePanelGroup>Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| orientation | select | horizontal | `horizontal`, `vertical` | Split axis of the panel group. | horizontal for side-by-side editors; vertical for stacked panes. Default: horizontal. |
| panels | number | 2 | 2–6 step 1 count | How many panels share the track (initial equal %). | 2 for master-detail; 3 for IDE-like columns. Must match ResizablePanel count/indexes. Default: 2. |
Gotchas
- react
Root API is ResizablePanelGroup + ResizablePanel + ResizableHandle. Panels need index 0..n-1; use outside group throws.
- layout
Min panel size is 10%. Give the group an explicit height/width or flex grow.
Anatomía CSS
<div class="resizable"> <span class="resizable__handle"></span> <span class="resizable__handle--dragging"></span> <span class="resizable__handle-grip"></span> <span class="resizable__panel"></span> </div>
| Clase | Propósito |
|---|---|
resizable | root |
resizable--vertical | modifier |
resizable__handle | element |
resizable__handle--dragging | element |
resizable__handle-grip | element |
resizable__panel | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #f5f5f5 | accent |
| all | border | none | (unparsed) |
| all | hover-bg | #f5f5f5 | accent |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
background-color | var(--duration-150) | var(--easing-in-out) |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Resizable
Draggable panel layout. Horizontal or vertical.
Keyboard accessible (Arrow keys to resize).
Parts: .resizable, .resizable__panel, .resizable__handle
------------------------------------------------------------------------- */
/* ---- Group ---- */
.resizable {
display: flex;
width: 100%;
height: 100%;
overflow: hidden;
}
.resizable--vertical {
flex-direction: column;
}
/* ---- Panel ---- */
.resizable__panel {
overflow: auto;
min-width: 0;
min-height: 0;
}
/* ---- Handle ---- */
.resizable__handle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: none;
border: none;
padding: 0;
cursor: col-resize;
-webkit-tap-highlight-color: transparent;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.resizable--vertical .resizable__handle {
cursor: row-resize;
}
/* Horizontal handle: thin vertical bar */
.resizable:not(.resizable--vertical) > .resizable__handle {
width: 8px;
}
/* Vertical handle: thin horizontal bar */
.resizable--vertical > .resizable__handle {
height: 8px;
}
.resizable__handle:hover {
background-color: #f5f5f5;
}
.resizable__handle:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: -2px;
}
/* ---- Visible grip ---- */
.resizable__handle-grip {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
background-color: #e5e5e5;
}
.resizable:not(.resizable--vertical) .resizable__handle-grip {
width: 4px;
height: 24px;
}
.resizable--vertical .resizable__handle-grip {
width: 24px;
height: 4px;
}
/* ---- Active drag state ---- */
.resizable__handle--dragging {
background-color: #f5f5f5;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.resizable__handle {
transition-duration: 0ms;
}
}
Codigo fuente
import { type ReactNode, type KeyboardEvent, type MouseEvent as ReactMouseEvent, useState, useRef, useCallback, useEffect, createContext, useContext, } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } /* ---- Context ---- */ type ResizableContextValue = { orientation: 'horizontal' | 'vertical'; sizes: number[]; onResize: (handleIndex: number, delta: number) => void; }; const ResizableContext = createContext<ResizableContextValue | null>(null); function useResizable() { const ctx = useContext(ResizableContext); if (!ctx) throw new Error('Resizable components must be used within <ResizablePanelGroup>'); return ctx; } /* ---- Panel Group ---- */ export type ResizablePanelGroupProps = { orientation?: 'horizontal' | 'vertical'; panels?: number; children: ReactNode; className?: string; }; export function ResizablePanelGroup({ orientation = 'horizontal', panels = 2, children, className, }: ResizablePanelGroupProps) { const [sizes, setSizes] = useState<number[]>(() => { const defaultSize = 100 / panels; return Array(panels).fill(defaultSize); }); const groupRef = useRef<HTMLDivElement>(null); const onResize = useCallback( (handleIndex: number, delta: number) => { if (!groupRef.current) return; const rect = groupRef.current.getBoundingClientRect(); const totalPx = orientation === 'horizontal' ? rect.width : rect.height; const deltaPct = (delta / totalPx) * 100; setSizes((prev) => { const next = [...prev]; const minSize = 10; const a = next[handleIndex] + deltaPct; const b = next[handleIndex + 1] - deltaPct; if (a < minSize || b < minSize) return prev; next[handleIndex] = a; next[handleIndex + 1] = b; return next; }); }, [orientation], ); return ( <ResizableContext.Provider value={{ orientation, sizes, onResize }}> <div ref={groupRef} className={cn('resizable', orientation === 'vertical' && 'resizable--vertical', className)} > {children} </div> </ResizableContext.Provider> ); } /* ---- Panel ---- */ export type ResizablePanelProps = { index: number; children: ReactNode; className?: string; }; export function ResizablePanel({ index, children, className }: ResizablePanelProps) { const { orientation, sizes } = useResizable(); const size = sizes[index] ?? 50; const style = orientation === 'horizontal' ? { width: `${size}%` } : { height: `${size}%` }; return ( <div className={cn('resizable__panel', className)} style={style}> {children} </div> ); } /* ---- Handle ---- */ export type ResizableHandleProps = { index: number; withHandle?: boolean; className?: string; }; export function ResizableHandle({ index, withHandle = false, className }: ResizableHandleProps) { const { orientation, onResize } = useResizable(); const [dragging, setDragging] = useState(false); const startPos = useRef(0); const handleMouseDown = (e: ReactMouseEvent) => { e.preventDefault(); setDragging(true); startPos.current = orientation === 'horizontal' ? e.clientX : e.clientY; const handleMouseMove = (ev: MouseEvent) => { const current = orientation === 'horizontal' ? ev.clientX : ev.clientY; const delta = current - startPos.current; if (delta !== 0) { onResize(index, delta); startPos.current = current; } }; const handleMouseUp = () => { setDragging(false); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; // Touch support const handleTouchStart = (e: React.TouchEvent) => { const touch = e.touches[0]; startPos.current = orientation === 'horizontal' ? touch.clientX : touch.clientY; setDragging(true); const handleTouchMove = (ev: TouchEvent) => { const t = ev.touches[0]; const current = orientation === 'horizontal' ? t.clientX : t.clientY; const delta = current - startPos.current; if (delta !== 0) { onResize(index, delta); startPos.current = current; } }; const handleTouchEnd = () => { setDragging(false); document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); }; document.addEventListener('touchmove', handleTouchMove, { passive: true }); document.addEventListener('touchend', handleTouchEnd); }; // Keyboard const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { const step = 20; const isH = orientation === 'horizontal'; if ((isH && e.key === 'ArrowRight') || (!isH && e.key === 'ArrowDown')) { e.preventDefault(); onResize(index, step); } else if ((isH && e.key === 'ArrowLeft') || (!isH && e.key === 'ArrowUp')) { e.preventDefault(); onResize(index, -step); } }; // Prevent text selection during drag useEffect(() => { if (!dragging) return; const prev = document.body.style.userSelect; document.body.style.userSelect = 'none'; return () => { document.body.style.userSelect = prev; }; }, [dragging]); return ( <div role="separator" tabIndex={0} aria-orientation={orientation} className={cn('resizable__handle', dragging && 'resizable__handle--dragging', className)} onMouseDown={handleMouseDown} onTouchStart={handleTouchStart} onKeyDown={handleKeyDown} > {withHandle && <div className="resizable__handle-grip" />} </div> ); }