Resizable
Headless components for creating resizable elements.
Quick Start
-
Define the resizable components
resizable.tsx 'use client'import { defineResizable } from 'crustack/resizable'const resizable = defineResizable() -
Create a resizable element with the
Root
component.You should provide boundary constraints like
minHeight
,maxHeight
,minWidth
, andmaxWidth
to control resizing behavior.import { Size } from 'crustack/resizable'import { resizable } from './resizable'export function MyComponent() {const [size, setSize] = useState<Size>({ width: '50%', height: '300px' })return (<resizable.Rootsize={size}onResize={setSize}className="relative min-w-8 min-h-8 max-w-full max-h-full">Resizable Content</resizable.Root>)} -
Add Resize Handles with the
Handle
component.The Handle component lets you add draggable handles for resizing.
- The
ratio
prop defines which direction the resizing occurs (e.g., horizontally, vertically, or both). - The
cursor
prop defines the cursor style during the resize operation and ensures consistency by applying it to both the handle and the body when the handle is active.
Here’s how to add handles for different sides and corners:
import { Size } from 'crustack/resizable'import { resizable } from './resizable'export function MyComponent() {const [size, setSize] = useState<Size>({ width: '50%', height: '300px' })return (<resizable.Rootsize={size}onResize={setSize}className="relative min-w-8 min-h-8 max-w-full max-h-full">Resizable Content{/* Right handle */}<resizable.Handleratio={{ width: 1, height: 0 }}cursor="col-resize"className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-teal-500"/>{/* Bottom handle */}<resizable.Handleratio={{ width: 0, height: 1 }}cursor="row-resize"className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-teal-500"/>{/* Bottom-Right handle */}<resizable.Handleratio={{ width: 1, height: 1 }}cursor="move"className="absolute right-0 bottom-0 translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-teal-500"/></resizable.Root>)} - The
import { Size } from 'crustack/resizable'import { resizable } from './resizable'
export function ResizableComponent() { const [size, setSize] = useState<Size>({ width: '50%', height: '300px' })
return ( <resizable.Root size={size} onResize={setSize} className="relative min-w-8 min-h-8 max-w-full max-h-full" > {/* Content */} <div>...</div>
{/* Right handle */} <resizable.Handle ratio={{ width: 1, height: 0 }} className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-teal-500" /> {/* Bottom handle */} <resizable.Handle ratio={{ width: 0, height: 1 }} className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-teal-500" /> {/* Bottom-Right handle */} <resizable.Handle ratio={{ width: 1, height: 1 }} className="absolute right-0 bottom-0 translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-teal-500" /> </resizable.Root> )}
Examples
1. Left aligned Sidebar
This example demonstrates how to build a Left aligned resizable sidebar.
The width of the sidebar is persisted in the cookies.
'use client'import { Size } from 'crustack/resizable'import { resizable } from './resizable'import { useCookie } from './cookies'
export const Sidebar = () => { const [width, setWidth] = useCookie<Size>('sidebar-width')
return ( <resizable.Root // use the width only size={{ width }} onResize={({ width }) => setWidth(width)} // place the sidebar and define its bounds className="relative h-screen min-w-24 max-w-[50vw]" // skip rendering the default div, forward all props to the child instead asChild > <nav> <resizable.Handle // can resize the width from the right side ratio={{ width: 1, height: 0 }} // position & style the handle className="transition absolute z-10 right-0 top-0 h-full translate-x-1/2 w-1.5 hover:bg-teal-700/30 data-[active]:bg-teal-700/50 overflow-auto" // cursor style cursor="col-resize" /> <ul> <li>...</li> <li>...</li> <li>...</li> </ul> </nav> </resizable.Root> )}
2. Right aligned Sidebar
Building off the Left aligned Sidebar example,we will modify the handle’s position and the resize behavior to create a Right-aligned sidebar.
By default, if you place the handle on the left side of the Root, resizing behaves inversely
to what you’d expect. To fix this, we’ll use the ratio
prop to invert the width calculations.
'use client'import { Size } from 'crustack/resizable'import { resizable } from './resizable'import { useCookie } from './cookies'
export const Sidebar = () => { const [width, setWidth] = useCookie('sidebar-width')
return ( <resizable.Root size={{ width }} onResize={({ width }) => setWidth(width)} className="relative h-screen min-w-24 max-w-[50vw]" asChild > <nav> <resizable.Handle // invert the calculations of the width resizing ratio={{ width: -1, height: 0 }} ratio={{ width: 1, height: 0 }} // place the handle on the left side className="transition absolute z-10 left-0 top-0 h-full -translate-x-1/2 w-1.5 hover:bg-teal-700/30 data-[active]:bg-teal-700/50 overflow-auto" className="transition absolute z-10 right-0 top-0 h-full translate-x-1/2 w-1.5 hover:bg-teal-700/30 data-[active]:bg-teal-700/50 overflow-auto" cursor="col-resize" /> <ul> <li>...</li> <li>...</li> <li>...</li> </ul> </nav> </resizable.Root> )}
3. Resize Direction & Centered elements
When working with elements that are centered, using a ratio
of 1
or -1
will cause
the handle to move at half the speed of the mouse. This happens because the handle is
centered, and the resizing effect is spread across both sides of the element.
To fix this behavior, you should use ratio
values of 2
or -2
, effectively doubling
the movement of the handle so it matches the mouse speed.
As a general rule:
- use a negative ratio for top and left handles
- use a positive ratio for bottom and right handles
The following example demonstrates how to apply the ratio
for all possible handle positions:
import { defineResizable, Size } from 'crustack/resizable'import { useState } from 'react'
function Centered() { const [size, setSize] = useState<Size>({ width: '300px', height: '300px', })
return ( // The outer div centers the Root on the screen <div className="w-screen h-screen flex items-center justify-center"> <resizable.Root className="relative" size={size} onResize={setSize}> {/* Top handle */} <resizable.Handle cursor="row-resize" ratio={{ width: 0, height: -2 }} className="absolute left-1/2 top-0 -translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Top-Right handle */} <resizable.Handle cursor="move" ratio={{ width: 2, height: -2 }} className="absolute right-0 top-0 -translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Right handle */} <resizable.Handle cursor="col-resize" ratio={{ width: 2, height: 0 }} className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Bottom-Right handle */} <resizable.Handle cursor="move" ratio={{ width: 2, height: 2 }} className="absolute right-0 bottom-0 translate-y-1/2 translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Bottom handle */} <resizable.Handle cursor="row-resize" ratio={{ width: 0, height: 2 }} className="absolute left-1/2 bottom-0 translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Bottom-Left handle */} <resizable.Handle cursor="move" ratio={{ width: -2, height: 2 }} className="absolute left-0 bottom-0 translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Left handle */} <resizable.Handle cursor="col-resize" ratio={{ width: -2, height: 0 }} className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> {/* Top-Left handle */} <resizable.Handle cursor="move" ratio={{ width: -2, height: -2 }} className="absolute left-0 top-0 -translate-y-1/2 -translate-x-1/2 size-4 bg-slate-200 data-[active]:bg-red-500" /> </resizable.Root> </div> )}
4. Usage with Tailwind
You might want to leverage Tailwind to define the initial size of your elements, particularly when adjusting sizes across breakpoints and media queries.
This can be done by omitting the width
and/or height
properties from the
initial size
object, allowing Tailwind classes to dictate the initial dimensions.
import { defineResizable, Size } from 'crustack/resizable'import { useState } from 'react'
function Resizable() { // uncontrolled initial size const [size, setSize] = useState<Size>({})
return ( <resizable.Root /** * the default size is: * - mobile device: width: 100%; height: 100% * - tablet / desktop: width: 300px; height: 300px */ className="relative w-full md:w-[300px] h-full md:h-[300px]" /** * Set the size when resize occurs */ onResize={setSize} /** * The size is controlled by: * - the className initially * - the size state after a resize occurs */ size={size} > ... </resizable.Root> )}
5. Improving Performance
Resizing DOM elements can be performance-intensive because each size change triggers a browser reflow and repaint. To minimize this, you can use the resizable element as a mask, following these steps:
- Set a positioning context (e.g. “relative”) for the element you want to resize.
- disable
flex-shrink
to prevent unexpected behaviors. - Position the
Root
andHandle
(s) on top of the target element, matching its size. - Disable pointer events on the
Root
but re-enable them on theHandle
(s) for interactivity. - Update the width of the target element using the
onResizeEnd
event.
import { useState } from 'react'import { Size } from 'crustack/resizable'import { resizable } from './resizable'
function Sidebar(props: LayoutProps) { const [resizableWidth, setResizableWidth] = useState<Size['width']>() const [sidebarWidth, setSidebarWidth] = useState<Size['width']>()
return ( <nav style={{ width: sidebarWidth }} // disable `flex-shrink` to prevent unexpected behaviors className="relative shrink-0 h-screen min-w-24 max-w-[50vw]" > <ul> <li>...</li> <li>...</li> <li>...</li> </ul>
<resizable.Root size={{ width: resizableWidth }} // position on top the sidebar // disable pointer events // add a semi-transparent background when resizing className="absolute inset-0 pointer-events-none data-[resizing]:bg-cyan-500/10" // Continuously update the Root width onResize={({ width }) => setResizableWidth(width)} // Update the sidebar width when resizing ends onResizeEnd={() => setSidebarWidth(resizableWidth)} > <resizable.Handle ratio={{ width: 1, height: 0 }} cursor="col-resize" // Position the handle on the right edge // enable pointer events className="pointer-events-auto absolute left-full inset-y-0 w-2 -translate-x-1/2 hover:bg-cyan-500/50 data-[active]:bg-cyan-500" /> </resizable.Root> </nav> )}
API Reference
resizable.Root
The resizable element
Prop | Default | type | description |
---|---|---|---|
asChild | false | boolean | Change the default rendered element for the one passed as a child, merging their props and behavior. |
size | (required) | { width?: string | number, height?: string | number } | The size of the element. |
locked | false | boolean | When true , disables all resizing handles. |
onResize | - | (size: { width: string | number, height: string | number }) => void | Called whenever the root is resized via a handle. |
onResizeEnd | - | () => void | Triggered when a resizing operation is finished (handle is released). |
children | - | React.ReactNode | ((props: State) => React.ReactNode) | children can be rendered using the renderProps pattern, exposing { height, width, isResizing } . |
The resizable element receives the following data-attributes:
data-attribute | type | description |
---|---|---|
data-resizable | 'true' | Always 'true' |
data-resizing | 'true' | undefined | Applied only when the element is being resized. |
data-locked | 'true' | undefined | Applied only when locked is true |
resizable.Handle
The Handles for resizing the Root element.
Prop | Default | type | description |
---|---|---|---|
asChild | false | boolean | Change the default rendered element for the one passed as a child, merging their props and behavior. |
ratio | (required) | { width: number, height: number } | Determines the speed and direction of the resizing |
onResize | - | (size: { width: string | number, height: string | number }) => void | Called whenever the root is resized via a handle. |
onResizeEnd | - | (size: { width: string | number, height: string | number }) => void | Triggered when a resizing operation is finished (handle is released). |
children | - | React.ReactNode | ((props: State) => React.ReactNode) | children can be rendered using the renderProps pattern, exposing { isActive } |
The resizable element receives the following data-attributes:
data-attribute | type | description |
---|---|---|
data-resizable | 'true' | Always 'true' |
data-active | 'true' | undefined | Applied when the handle is actively resizing. |
data-locked | 'true' | undefined | Applied when the Root ’s locked prop is true . |