Skip to content

Resizable

Size
1.61 kb
View source

Headless components for creating resizable elements.

Quick Start

  1. Define the resizable components

    resizable.tsx
    'use client'
    import { defineResizable } from 'crustack/resizable'
    const resizable = defineResizable()
  2. Create a resizable element with the Root component.

    You should provide boundary constraints like minHeight, maxHeight, minWidth, and maxWidth 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.Root
    size={size}
    onResize={setSize}
    className="relative min-w-8 min-h-8 max-w-full max-h-full"
    >
    Resizable Content
    </resizable.Root>
    )
    }
  3. 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.Root
    size={size}
    onResize={setSize}
    className="relative min-w-8 min-h-8 max-w-full max-h-full"
    >
    Resizable Content
    {/* Right handle */}
    <resizable.Handle
    ratio={{ 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.Handle
    ratio={{ 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.Handle
    ratio={{ 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>
    )
    }
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 and Handle(s) on top of the target element, matching its size.
  • Disable pointer events on the Root but re-enable them on the Handle(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

PropDefaulttypedescription
asChildfalse
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.
lockedfalse
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-attributetypedescription
data-resizable'true'Always 'true'
data-resizing'true' | undefinedApplied only when the element is being resized.
data-locked'true' | undefinedApplied only when locked is true

resizable.Handle

The Handles for resizing the Root element.

PropDefaulttypedescription
asChildfalse
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-attributetypedescription
data-resizable'true'Always 'true'
data-active'true' | undefinedApplied when the handle is actively resizing.
data-locked'true' | undefinedApplied when the Root’s locked prop is true.