Resizable
freeResizable 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 ResizableExamples
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.
| Prop | Description | Type | Default |
|---|---|---|---|
asElement | Element or component used for the resizable root. | ElementType | 'div' |
bounds | Limits resizing to the parent, window, or a specific element. | 'parent' | 'window' | HTMLElement | - |
boundsByDirection | Applies directional bounds from the side being dragged. | boolean | false |
children | Content rendered inside the resizable surface. | ReactNode | - |
className | Class names applied to the root element. | string | - |
classNames | Slot class names for the root, wrapper, and each handle. | ResizableClassNames | - |
defaultSize | Initial uncontrolled size. | Size | { width: 'auto', height: 'auto' } |
disabled | Prevents handles from starting a resize interaction. | boolean | false |
handles | Canonical handle configuration. Omit it for all default handles, pass false for no handles, or pass an explicit position list. | false | ResizeHandleConfig[] | all directions |
enable | Legacy direction map used when handles is not provided. | Enable | false | all directions |
grid | Snaps resize movement to horizontal and vertical steps. | [number, number] | [1, 1] |
gridGap | Gap offset used when snapping to grid. | [number, number] | [0, 0] |
handleClasses | Legacy alias for per-handle class names. | HandleClassNames | - |
handleClassNames | Class names applied to individual handle hit areas. | HandleClassNames | - |
handleComponent | Legacy alias for per-handle custom content. | HandleComponents | - |
handleComponents | Legacy custom content map used when handles is not provided. | HandleComponents | - |
handleStyles | Inline styles applied to individual handle hit areas. | HandleStyles | - |
handleWrapperClass | Legacy alias for the handle wrapper class name. | string | - |
handleWrapperClassName | Class names applied to the handle wrapper. | string | - |
handleWrapperStyle | Inline styles applied to the handle wrapper. | CSSProperties | - |
lockAspectRatio | Preserves the current ratio, or uses a provided ratio number. | boolean | number | false |
lockAspectRatioExtraHeight | Extra height included while preserving ratio. | number | 0 |
lockAspectRatioExtraWidth | Extra width included while preserving ratio. | number | 0 |
maxHeight | Maximum height as pixels or a CSS size. | SizeValue | - |
maxWidth | Maximum width as pixels or a CSS size. | SizeValue | - |
minHeight | Minimum height as pixels or a CSS size. | SizeValue | - |
minWidth | Minimum width as pixels or a CSS size. | SizeValue | - |
onResize | Called while a handle is dragged. | ResizeCallback | - |
onResizeStart | Called before resizing starts; return false to cancel. | ResizeStartCallback | - |
onResizeStop | Called when pointer interaction ends. | ResizeCallback | - |
ref | Ref forwarded to the root element. | Ref<HTMLElement> | - |
resizeRatio | Multiplier for pointer movement on each axis. | number | [number, number] | 1 |
scale | Pointer scale compensation for transformed parents. | number | 1 |
size | Controlled size. | Size | - |
snap | Snap points for width and height. | { x?: number[]; y?: number[] } | - |
snapGap | Maximum distance from a snap point before snapping applies. | number | 0 |
style | Inline styles applied to the root element. | CSSProperties | - |
...nativeProps | Native 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>
>;