Resizable

free

Resizable lets users drag a surface to adjust working space without leaving the page.

Preview
Dark
Sidebar
Content
import Resizable, {
  type ResizeHandleConfig,
} from '@/components/ui/Resizable';

const handleClassName = [
  'pointer-events-none absolute left-1/2 top-1/2 z-[1]',
  'flex h-8 w-4 -translate-x-1/2 -translate-y-1/2',
  'items-center justify-center rounded-control-sm border',
  'border-border bg-card text-muted-foreground shadow-card',
].join(' ');

function ResizeHandle() {
  return (
    <span className={handleClassName}>
      <span className="grid grid-cols-2 gap-x-1 gap-y-1">
        {Array.from({ length: 6 }).map((_, index) => (
          <span
            key={index}
            className="size-0.5 rounded-full bg-current"
          />
        ))}
      </span>
    </span>
  );
}

const handles: ResizeHandleConfig[] = [
  { position: 'right', handler: <ResizeHandle /> },
];

const shellClassName = [
  'flex h-52 w-full max-w-lg overflow-hidden',
  'rounded-card border bg-card',
].join(' ');

const paneClassName = [
  'flex h-full items-center justify-center',
  'text-sm font-semibold text-foreground',
].join(' ');

const contentClassName = [
  'flex min-w-0 flex-1 items-center justify-center',
  'text-sm font-semibold text-foreground',
].join(' ');

export default function UsageDemo() {
  return (
    <div className={shellClassName}>
      <Resizable
        className="border-r bg-card"
        defaultSize={{ width: 144, height: '100%' }}
        handles={handles}
        minWidth={112}
        maxWidth={220}
      >
        <div className={paneClassName}>Sidebar</div>
      </Resizable>
      <div className={contentClassName}>Content</div>
    </div>
  );
}

Installation

Add this component with the NateUI CLI.

npx nateui@latest add Resizable

Examples

Default Handles

Preview
Dark
import Resizable from '@/components/ui/Resizable';

const contentClassName = [
  'grid h-full place-items-center rounded-control border',
  'border-dashed bg-muted text-center',
  'text-sm font-medium text-muted-foreground',
].join(' ');

export default function DefaultHandlesDemo() {
  return (
    <Resizable
      className="rounded-card border bg-card p-4 shadow-card"
      defaultSize={{ width: 320, height: 180 }}
      minHeight={130}
      minWidth={240}
      maxHeight={260}
      maxWidth={440}
    >
      <div className={contentClassName} />
    </Resizable>
  );
}

Handle Set

Preview
Dark
Only the configured edge and corner handles are rendered.
import Resizable, {
  type ResizeHandleConfig,
} from '@/components/ui/Resizable';

const handles: ResizeHandleConfig[] = [
  { position: 'right' },
  { position: 'bottom' },
  { position: 'bottomRight' },
];

const contentClassName = [
  'flex h-full items-center justify-center text-center',
  'text-sm font-medium text-muted-foreground',
].join(' ');

export default function HandleSetDemo() {
  return (
    <Resizable
      className="rounded-card border bg-card p-4 shadow-card"
      defaultSize={{ width: 330, height: 170 }}
      handles={handles}
      minHeight={130}
      minWidth={240}
      maxHeight={250}
      maxWidth={460}
    >
      <div className={contentClassName}>
        Only the configured edge and corner handles are rendered.
      </div>
    </Resizable>
  );
}

Custom Handles

Preview
Dark
Custom resize points

Custom content replaces the visible square while the hitbox remains.

import Resizable, {
  type ResizeHandleConfig,
} from '@/components/ui/Resizable';

const customHandleClassName = [
  'pointer-events-none absolute left-1/2 top-1/2 z-[1]',
  'flex h-8 w-4 -translate-x-1/2 -translate-y-1/2',
  'items-center justify-center rounded-control-sm border',
  'border-border bg-card text-muted-foreground shadow-card',
].join(' ');

function CustomHandle() {
  return (
    <span className={customHandleClassName}>
      <span className="grid grid-cols-2 gap-x-1 gap-y-1">
        {Array.from({ length: 6 }).map((_, index) => (
          <span
            key={index}
            className="size-0.5 rounded-full bg-current"
          />
        ))}
      </span>
    </span>
  );
}

const handles: ResizeHandleConfig[] = [
  { position: 'left', handler: <CustomHandle /> },
  { position: 'right', handler: <CustomHandle /> },
];

export default function CustomHandlesDemo() {
  return (
    <Resizable
      className="rounded-card border bg-card p-4 shadow-card"
      defaultSize={{ width: 320, height: 170 }}
      handles={handles}
      minHeight={130}
      minWidth={240}
      maxHeight={260}
      maxWidth={460}
    >
      <div className="flex h-full flex-col justify-between">
        <h5>Custom resize points</h5>
        <p className="text-sm text-muted-foreground">
          Custom content replaces the visible square while the hitbox remains.
        </p>
      </div>
    </Resizable>
  );
}

Grouped

Preview
Dark
Sidebar
Editor
Content
import Resizable, {
  type ResizeHandleConfig,
} from '@/components/ui/Resizable';

const sidebarHandles: ResizeHandleConfig[] = [
  { position: 'right', handler: <div /> },
];

const editorHandles: ResizeHandleConfig[] = [
  { position: 'bottom', handler: <div /> },
];

const groupClassName = [
  'flex h-80 w-full max-w-3xl overflow-hidden',
  'rounded-card border bg-card',
].join(' ');

const paneLabelClassName = [
  'flex h-full items-center justify-center',
  'text-sm font-medium text-foreground',
].join(' ');

const contentClassName = [
  'flex min-h-0 flex-1 items-center justify-center',
  'bg-muted text-sm font-medium text-foreground',
].join(' ');

export default function GroupedDemo() {
  return (
    <div className={groupClassName}>
      <Resizable
        className="border-r border bg-card"
        defaultSize={{ width: 184, height: '100%' }}
        handles={sidebarHandles}
        minWidth={140}
        maxWidth={280}
      >
        <div className={paneLabelClassName}>Sidebar</div>
      </Resizable>
      <div className="flex min-w-0 flex-1 flex-col">
        <Resizable
          className="border-b bg-card"
          defaultSize={{ width: '100%', height: 96 }}
          handles={editorHandles}
          minHeight={72}
          maxHeight={180}
        >
          <div className={paneLabelClassName}>Editor</div>
        </Resizable>
        <div className={contentClassName}>Content</div>
      </div>
    </div>
  );
}

Controlled Size

Preview
Dark
Controlled panel
310px x 176px
import { useState } from 'react';
import Button from '@/components/ui/Button';
import Resizable, {
  type ResizeCallback,
} from '@/components/ui/Resizable';

const defaultSize = { width: 310, height: 176 };

const sizeLabelClassName = [
  'rounded-control border bg-muted px-3 py-2',
  'text-xs text-muted-foreground',
].join(' ');

export default function ControlledSizeDemo() {
  const [size, setSize] = useState(defaultSize);

  const syncSize: ResizeCallback = (_event, _direction, element) => {
    setSize({
      width: element.offsetWidth,
      height: element.offsetHeight,
    });
  };

  return (
    <div className="flex flex-col gap-4">
      <Resizable
        className="rounded-card border bg-card p-4 shadow-card"
        minHeight={136}
        minWidth={240}
        maxHeight={260}
        maxWidth={440}
        size={size}
        onResize={syncSize}
        onResizeStop={syncSize}
      >
        <div className="flex h-full flex-col justify-between">
          <h5>Controlled panel</h5>
          <div className={sizeLabelClassName}>
            {size.width}px x {size.height}px
          </div>
        </div>
      </Resizable>
      <Button
        className="w-fit"
        size="sm"
        onClick={() => setSize(defaultSize)}
      >
        Reset size
      </Button>
    </div>
  );
}

Grid

Preview
Dark
Grid snapped

Size changes land on a 24px rhythm.

import Resizable from '@/components/ui/Resizable';

const contentClassName = [
  'flex h-full flex-col justify-between rounded-control border',
  'border-dashed bg-muted p-4',
].join(' ');

export default function GridDemo() {
  return (
    <Resizable
      className="rounded-card border bg-card p-4 shadow-card"
      defaultSize={{ width: 288, height: 168 }}
      grid={[24, 24]}
      minHeight={120}
      minWidth={216}
      maxHeight={288}
      maxWidth={432}
    >
      <div className={contentClassName}>
        <h5>Grid snapped</h5>
        <p className="text-sm text-muted-foreground">
          Size changes land on a 24px rhythm.
        </p>
      </div>
    </Resizable>
  );
}

Bounds And Ratio

Preview
Dark
Aspect ratio stays locked inside the parent bounds.
import Resizable from '@/components/ui/Resizable';

const parentClassName = [
  'relative h-72 w-full max-w-3xl overflow-hidden',
  'rounded-card border border-dashed bg-muted p-4',
].join(' ');

const contentClassName = [
  'flex h-full items-center justify-center text-center',
  'text-sm font-medium text-muted-foreground',
].join(' ');

export default function BoundsRatioDemo() {
  return (
    <div className={parentClassName}>
      <Resizable
        bounds="parent"
        className="rounded-card border bg-card p-4 shadow-card"
        defaultSize={{ width: 280, height: 160 }}
        lockAspectRatio
        minHeight={120}
        minWidth={210}
        maxWidth="100%"
      >
        <div className={contentClassName}>
          Aspect ratio stays locked inside the parent bounds.
        </div>
      </Resizable>
    </div>
  );
}

API

Resizable renders a positioned element with draggable handles. It can manage its own size from defaultSize, or accept a controlled size object and report changes through resize callbacks.

PropDescriptionTypeDefault
asElementElement or component used for the resizable root.ElementType'div'
boundsLimits resizing to the parent, window, or a specific element.'parent' | 'window' | HTMLElement-
boundsByDirectionApplies directional bounds from the side being dragged.booleanfalse
childrenContent rendered inside the resizable surface.ReactNode-
classNameClass names applied to the root element.string-
classNamesSlot class names for the root, wrapper, and each handle.ResizableClassNames-
defaultSizeInitial uncontrolled size.Size{ width: 'auto', height: 'auto' }
disabledPrevents handles from starting a resize interaction.booleanfalse
handlesCanonical handle configuration. Omit it for all default handles, pass false for no handles, or pass an explicit position list.false | ResizeHandleConfig[]all directions
enableLegacy direction map used when handles is not provided.Enable | falseall directions
gridSnaps resize movement to horizontal and vertical steps.[number, number][1, 1]
gridGapGap offset used when snapping to grid.[number, number][0, 0]
handleClassesLegacy alias for per-handle class names.HandleClassNames-
handleClassNamesClass names applied to individual handle hit areas.HandleClassNames-
handleComponentLegacy alias for per-handle custom content.HandleComponents-
handleComponentsLegacy custom content map used when handles is not provided.HandleComponents-
handleStylesInline styles applied to individual handle hit areas.HandleStyles-
handleWrapperClassLegacy alias for the handle wrapper class name.string-
handleWrapperClassNameClass names applied to the handle wrapper.string-
handleWrapperStyleInline styles applied to the handle wrapper.CSSProperties-
lockAspectRatioPreserves the current ratio, or uses a provided ratio number.boolean | numberfalse
lockAspectRatioExtraHeightExtra height included while preserving ratio.number0
lockAspectRatioExtraWidthExtra width included while preserving ratio.number0
maxHeightMaximum height as pixels or a CSS size.SizeValue-
maxWidthMaximum width as pixels or a CSS size.SizeValue-
minHeightMinimum height as pixels or a CSS size.SizeValue-
minWidthMinimum width as pixels or a CSS size.SizeValue-
onResizeCalled while a handle is dragged.ResizeCallback-
onResizeStartCalled before resizing starts; return false to cancel.ResizeStartCallback-
onResizeStopCalled when pointer interaction ends.ResizeCallback-
refRef forwarded to the root element.Ref<HTMLElement>-
resizeRatioMultiplier for pointer movement on each axis.number | [number, number]1
scalePointer scale compensation for transformed parents.number1
sizeControlled size.Size-
snapSnap points for width and height.{ x?: number[]; y?: number[] }-
snapGapMaximum distance from a snap point before snapping applies.number0
styleInline styles applied to the root element.CSSProperties-
...nativePropsNative props for the rendered root element.ComponentPropsWithoutRef<E>-

Types

type ResizeDirection =
  | 'top'
  | 'right'
  | 'bottom'
  | 'left'
  | 'topRight'
  | 'bottomRight'
  | 'bottomLeft'
  | 'topLeft';
 
type ResizeHandleConfig = {
  position: ResizeDirection;
  handler?: false | ReactNode;
};
 
type Enable = Partial<Record<ResizeDirection, boolean>>;
 
type SizeValue = string | number;
 
type Size = {
  width?: SizeValue;
  height?: SizeValue;
};
 
type NumberSize = {
  width: number;
  height: number;
};
 
type ResizeCallback = (
  event: PointerEvent,
  direction: ResizeDirection,
  elementRef: HTMLElement,
  delta: NumberSize,
) => void;
 
type ResizeStartCallback = (
  event: ReactPointerEvent<HTMLElement>,
  direction: ResizeDirection,
  elementRef: HTMLElement,
) => void | boolean;
 
type HandleClassNames = Partial<Record<ResizeDirection, string>>;
type HandleComponents = Partial<Record<ResizeDirection, ReactNode>>;
type HandleStyles = Partial<Record<ResizeDirection, CSSProperties>>;
 
type ResizableClassNames = Partial<
  Record<'root' | 'handleWrapper' | ResizeDirection, string>
>;