ATOM
Components (registry)Layout

Resizable

Drag a handle to grow or shrink adjacent panes. Split views and customizable workspaces.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
orientationselecthorizontal`horizontal`, `vertical`Split axis of the panel group.horizontal for side-by-side editors; vertical for stacked panes. Default: horizontal.
panelsnumber22–6 step 1 countHow 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>
ClasePropósito
resizableroot
resizable--verticalmodifier
resizable__handleelement
resizable__handle--draggingelement
resizable__handle-gripelement
resizable__panelelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#f5f5f5accent
allbordernone(unparsed)
allhover-bg#f5f5f5accent

Animaciones

PropiedadDuraciónEasing
background-colorvar(--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

components-react
components/atoms/Resizable.tsx
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>
  );
}

On this page

Detalles

Publicado14 de mayo de 2026
Categorialayout
Lectura...
Visitas...
Ayuda?Slack

Componente

Resizable

Source

components-react / cssDisponible via MCP: atom_uikit_source("resizable")
Abrir en Storybook