Crustack

@crustack/resizable

Headless components for creating resizable elements.

Headless components for creating resizable elements.

Quick Start

Define the resizable components

resizable.tsx
'use client'
import {  } from '@crustack/resizable'

const  = ()

Create a resizable element with the Root component.

You should provide boundary constraints like minHeight, maxHeight, minWidth, and maxWidth to control resizing behavior.

export function () {
  const [, ] = <>({ : '50%', : '300px' })

  return (
    <.
      ={}
      ={}
      ="relative max-h-full min-h-8 min-w-8 max-w-full"
    >
      Resizable Content
    </.>
  )
}

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:

export function () {
  const [, ] = <>({ : '50%', : '300px' })

  return (
    <.
      ={}
      ={}
      ="relative max-h-full min-h-8 min-w-8 max-w-full"
    >
      <>Resizable Content</>

      {/* Right handle */}
      <.
        ={{ : 1, : 0 }}
        ="col-resize"
        ="absolute right-0 top-1/2 size-4 -translate-y-1/2 translate-x-1/2 bg-slate-200 data-[active]:bg-teal-500"
      />
      {/* Bottom handle */}
      <.
        ={{ : 0, : 1 }}
        ="row-resize"
        ="absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-1/2 bg-slate-200 data-[active]:bg-teal-500"
      />
      {/* Bottom-Right handle */}
      <.
        ={{ : 1, : 1 }}
        ="move"
        ="absolute bottom-0 right-0 size-4 translate-x-1/2 translate-y-1/2 bg-slate-200 data-[active]:bg-teal-500"
      />
    </.>
  )
}

Examples

1. Left aligned Sidebar

This example demonstrates how to build a Left aligned resizable sidebar.

export const  = () => {
  const [, ] = (200)

  return (
    <.
      // use the width only
      ={{  }}
      ={({  }) => ()}
      // place the sidebar and define its bounds
      ="relative h-screen min-w-24 max-w-[50vw]"
      // skip rendering the default div, forward all props to the child instead
      
    >
      <>
        <.
          // can resize the width from the right side
          ={{ : 1, : 0 }}
          // position & style the handle
          ="absolute right-0 top-0 z-10 h-full w-1.5 translate-x-1/2 overflow-auto transition hover:bg-teal-700/30 data-[active]:bg-teal-700/50"
          // cursor style
          ="col-resize"
        />
        <>
          <>...</>
          <>...</>
          <>...</>
        </>
      </>
    </.>
  )
}

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.

export const Sidebar = () => {
  const [width, setWidth] = useState(200)

  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:

function () {
  const [, ] = <>({
    : '300px',
    : '300px',
  })

  return (
    // The outer div centers the Root on the screen
    < ="flex h-screen w-screen items-center justify-center">
      <. ="relative" ={} ={}>
        {/* Top handle */}
        <.
          ="row-resize"
          ={{ : 0, : -2 }}
          ="absolute left-1/2 top-0 size-4 -translate-x-1/2 -translate-y-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Top-Right handle */}
        <.
          ="move"
          ={{ : 2, : -2 }}
          ="absolute right-0 top-0 size-4 -translate-y-1/2 translate-x-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Right handle */}
        <.
          ="col-resize"
          ={{ : 2, : 0 }}
          ="absolute right-0 top-1/2 size-4 -translate-y-1/2 translate-x-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Bottom-Right handle */}
        <.
          ="move"
          ={{ : 2, : 2 }}
          ="absolute bottom-0 right-0 size-4 translate-x-1/2 translate-y-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Bottom handle */}
        <.
          ="row-resize"
          ={{ : 0, : 2 }}
          ="absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Bottom-Left handle */}
        <.
          ="move"
          ={{ : -2, : 2 }}
          ="absolute bottom-0 left-0 size-4 -translate-x-1/2 translate-y-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Left handle */}
        <.
          ="col-resize"
          ={{ : -2, : 0 }}
          ="absolute left-0 top-1/2 size-4 -translate-x-1/2 -translate-y-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
        {/* Top-Left handle */}
        <.
          ="move"
          ={{ : -2, : -2 }}
          ="absolute left-0 top-0 size-4 -translate-x-1/2 -translate-y-1/2 bg-slate-200 data-[active]:bg-red-500"
        />
      </.>
    </>
  )
}

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.

function () {
  // uncontrolled initial size
  const [, ] = <>({})

  return (
    <.
      /**
       * the default size is:
       * - mobile device: width: 100%; height: 100%
       * - tablet / desktop: width: 300px; height: 300px
       */
      ="relative h-full w-full md:h-[300px] md:w-[300px]"
      /**
       * Set the size when resize occurs
       */
      ={}
      /**
       * The size is controlled by:
       * - the className initially
       * - the size state after a resize occurs
       */
      ={}
    >
      ...
    </.>
  )
}

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.
function () {
  const [, ] = <['width']>()
  const [, ] = <['width']>()

  return (
    <
      ={{ :  }}
      // disable `flex-shrink` to prevent unexpected behaviors
      ="relative h-screen min-w-24 max-w-[50vw] shrink-0"
    >
      <>
        <>...</>
        <>...</>
        <>...</>
      </>

      <.
        ={{ :  }}
        // position on top the sidebar
        // disable pointer events
        // add a semi-transparent background when resizing
        ="pointer-events-none absolute inset-0 data-[resizing]:bg-cyan-500/10"
        // Continuously update the Root width
        ={({  }) => ()}
        // Update the sidebar width when resizing ends
        ={() => ()}
      >
        <.
          ={{ : 1, : 0 }}
          ="col-resize"
          // Position the handle on the right edge
          // enable pointer events
          ="pointer-events-auto absolute inset-y-0 left-full w-2 -translate-x-1/2 hover:bg-cyan-500/50 data-[active]:bg-cyan-500"
        />
      </.>
    </>
  )
}

API Reference

👉 resizable.Root

The resizable element

Props

PropTypeDefault
size
Size
-
onResizeEnd?
(() => void)
-
onResize?
((size: MeasuredSize) => void)
-
locked?
boolean
-
children?
ReactNode | ((props: Size & { isResizing: boolean; }) => ReactNode)
-
asChild?
boolean
-

Data Attributes

PropTypeDefault
data-locked?
"true" | undefined
-
data-resizing?
"true" | undefined
-
data-resizable?
"true"
-

👉 resizable.Handle

The Handles for resizing the Root element.

Props

PropTypeDefault
onResizeEnd?
(() => void)
-
onResize?
((size: MeasuredSize) => void)
-
ratio
Ratio
-
cursor?
Cursor
-
children?
ReactNode | ((props: { isActive: boolean; }) => ReactNode)
-
asChild?
boolean
-

Data Attributes

PropTypeDefault
data-locked?
"true" | undefined
-
data-active?
"true" | undefined
-
data-handle?
"true"
-