# Resizable

> Panels you can drag apart, with a quiet divider that wakes up on hover, sizes that glide on reset or collapse, and layouts that persist.

Docs: https://hextaui.com/docs/resizable
Markdown: https://hextaui.com/docs/resizable.md

```tsx title="components/examples/resizable/demo.tsx"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

function Pane({ title, children }: { title: string; children?: string }) {
  return (
    <div className="flex h-full flex-col gap-1 p-4">
      <span className="text-sm font-medium">{title}</span>
      <span className="text-sm text-muted-foreground">{children}</span>
    </div>
  )
}

export function ResizableDemo() {
  return (
    <div className="h-80 w-full max-w-2xl overflow-hidden rounded-xl border">
      <ResizablePanelGroup>
        <ResizablePanel defaultSize="28%" minSize="18%" maxSize="45%">
          <Pane title="Explorer">
            Drag the divider, or double-click it to reset.
          </Pane>
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel>
          <ResizablePanelGroup orientation="vertical">
            <ResizablePanel defaultSize="65%" minSize="25%">
              <Pane title="Editor">
                Focus a divider and use the arrow keys.
              </Pane>
            </ResizablePanel>
            <ResizableHandle />
            <ResizablePanel minSize="15%">
              <Pane title="Terminal" />
            </ResizablePanel>
          </ResizablePanelGroup>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/resizable.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add react-resizable-panels cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/resizable.tsx"
"use client"

import * as React from "react"
import { cn } from "cn"
import * as ResizablePrimitive from "react-resizable-panels"

function useMergedElementRef<T>(
  own: React.RefObject<T | null>,
  external: React.Ref<T | null> | undefined
) {
  return React.useCallback(
    (node: T | null) => {
      own.current = node
      if (typeof external === "function") {
        return external(node)
      }
      if (external) {
        external.current = node
      }
      return undefined
    },
    [own, external]
  )
}

function ResizablePanelGroup({
  className,
  elementRef,
  groupRef: groupHandleRef,
  defaultLayout,
  onPointerDownCapture,
  onKeyDownCapture,
  ...props
}: ResizablePrimitive.GroupProps) {
  const groupRef = React.useRef<HTMLDivElement | null>(null)
  const setRef = useMergedElementRef(groupRef, elementRef)
  const handleRef =
    React.useRef<ResizablePrimitive.GroupImperativeHandle | null>(null)
  const setHandleRef = useMergedElementRef(handleRef, groupHandleRef)
  const appliedLayout = React.useRef(defaultLayout)
  const interacted = React.useRef(false)

  React.useLayoutEffect(() => {
    const previous = appliedLayout.current
    appliedLayout.current = defaultLayout
    if (
      !defaultLayout ||
      interacted.current ||
      JSON.stringify(defaultLayout) === JSON.stringify(previous)
    ) {
      return
    }
    const element = groupRef.current
    const ready = element?.hasAttribute("data-ready")
    element?.removeAttribute("data-ready")
    handleRef.current?.setLayout(defaultLayout)
    if (ready) {
      requestAnimationFrame(() => {
        requestAnimationFrame(() => element?.setAttribute("data-ready", ""))
      })
    }
  }, [defaultLayout])

  React.useEffect(() => {
    let second = 0
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(() => {
        groupRef.current?.setAttribute("data-ready", "")
      })
    })
    return () => {
      cancelAnimationFrame(first)
      cancelAnimationFrame(second)
    }
  }, [])

  return (
    <ResizablePrimitive.Group
      data-slot="resizable-panel-group"
      elementRef={setRef}
      groupRef={setHandleRef}
      defaultLayout={defaultLayout}
      onPointerDownCapture={(event) => {
        onPointerDownCapture?.(event)
        interacted.current = true
      }}
      onKeyDownCapture={(event) => {
        onKeyDownCapture?.(event)
        interacted.current = true
      }}
      className={cn(
        "flex h-full w-full min-w-0 aria-[orientation=vertical]:flex-col data-ready:not-has-[>[data-separator=active]]:*:data-panel:transition-[flex-grow] data-ready:not-has-[>[data-separator=active]]:*:data-panel:duration-300 data-ready:not-has-[>[data-separator=active]]:*:data-panel:ease-out-quint motion-reduce:*:data-panel:transition-none",
        className
      )}
      {...props}
    />
  )
}

function ResizablePanel({
  className,
  ...props
}: ResizablePrimitive.PanelProps) {
  return (
    <ResizablePrimitive.Panel
      data-slot="resizable-panel"
      className={cn("min-w-0", className)}
      {...props}
    />
  )
}

type ResizableSizeUnit = "percent" | "pixels"

function useSizeReadout(
  separatorRef: React.RefObject<HTMLDivElement | null>,
  valueRef: React.RefObject<HTMLSpanElement | null>,
  unit: ResizableSizeUnit | null
) {
  React.useEffect(() => {
    const separator = separatorRef.current
    const value = valueRef.current
    if (!unit || !separator || !value) {
      return
    }
    let panel = separator.previousElementSibling
    while (panel && !panel.hasAttribute("data-panel")) {
      panel = panel.previousElementSibling
    }

    const update = () => {
      if (unit === "percent") {
        const now = Number(separator.getAttribute("aria-valuenow"))
        value.textContent = Number.isFinite(now) ? `${Math.round(now)}%` : ""
        return
      }
      if (!panel) {
        value.textContent = ""
        return
      }
      const rect = panel.getBoundingClientRect()
      const vertical =
        separator.getAttribute("aria-orientation") === "horizontal"
      value.textContent = `${Math.round(vertical ? rect.height : rect.width)}px`
    }

    update()
    const mutations = new MutationObserver(update)
    mutations.observe(separator, {
      attributes: true,
      attributeFilter: ["aria-valuenow"],
    })
    const resize =
      typeof ResizeObserver === "undefined" || !panel
        ? null
        : new ResizeObserver(update)
    if (panel) {
      resize?.observe(panel)
    }
    return () => {
      mutations.disconnect()
      resize?.disconnect()
    }
  }, [separatorRef, valueRef, unit])
}

type ResizableHandleProps = ResizablePrimitive.SeparatorProps & {
  withHandle?: boolean
  showSize?: boolean | ResizableSizeUnit
}

function ResizableHandle({
  withHandle = false,
  showSize = false,
  className,
  elementRef,
  children,
  ...props
}: ResizableHandleProps) {
  const separatorRef = React.useRef<HTMLDivElement | null>(null)
  const valueRef = React.useRef<HTMLSpanElement | null>(null)
  const setRef = useMergedElementRef(separatorRef, elementRef)
  const unit = showSize === true ? "percent" : showSize || null
  useSizeReadout(separatorRef, valueRef, unit)

  return (
    <ResizablePrimitive.Separator
      aria-label="Resize panels"
      data-slot="resizable-handle"
      data-with-handle={withHandle ? "" : undefined}
      elementRef={setRef}
      className={cn(
        "group/resizable-handle relative z-1 flex w-px shrink-0 items-center justify-center bg-border outline-none [--resizable-line:1px] before:absolute before:inset-y-0 before:start-1/2 before:w-(--resizable-line) before:-translate-x-1/2 before:bg-transparent before:transition-[width,height,background-color] before:duration-150 before:ease-out-quint focus-visible:outline-hidden focus-visible:[--resizable-line:3px] focus-visible:before:bg-focus-ring aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:before:inset-x-0 aria-[orientation=horizontal]:before:inset-y-auto aria-[orientation=horizontal]:before:start-0 aria-[orientation=horizontal]:before:top-1/2 aria-[orientation=horizontal]:before:h-(--resizable-line) aria-[orientation=horizontal]:before:w-auto aria-[orientation=horizontal]:before:translate-x-0 aria-[orientation=horizontal]:before:-translate-y-1/2 data-[separator=active]:[--resizable-line:3px] data-[separator=active]:before:bg-primary data-[separator=disabled]:cursor-default data-[separator=hover]:[--resizable-line:3px] data-[separator=hover]:before:bg-muted-foreground/40 motion-reduce:before:transition-none rtl:before:translate-x-1/2 forced-colors:bg-canvas-text",
        className
      )}
      {...props}
    >
      <span
        aria-hidden="true"
        data-slot="resizable-handle-grip"
        className="pointer-events-none relative h-8 w-1.5 shrink-0 scale-75 rounded-full bg-background opacity-0 inset-ring-(length:--hairline) inset-ring-border transition-[opacity,scale,box-shadow] duration-150 ease-out-quint group-focus-visible/resizable-handle:scale-100 group-focus-visible/resizable-handle:opacity-100 group-aria-[orientation=horizontal]/resizable-handle:h-1.5 group-aria-[orientation=horizontal]/resizable-handle:w-8 group-data-with-handle/resizable-handle:scale-100 group-data-with-handle/resizable-handle:opacity-100 group-data-[separator=active]/resizable-handle:scale-100 group-data-[separator=active]/resizable-handle:opacity-100 group-data-[separator=active]/resizable-handle:inset-ring-primary group-data-[separator=disabled]/resizable-handle:hidden group-data-[separator=hover]/resizable-handle:scale-100 group-data-[separator=hover]/resizable-handle:opacity-100 motion-reduce:transition-none dark:bg-muted forced-colors:border"
      />
      {unit ? (
        <span
          aria-hidden="true"
          data-slot="resizable-handle-size"
          className="pointer-events-none absolute start-1/2 top-1/2 translate-x-[-50%] -translate-y-[calc(50%+1.75rem)] scale-95 rounded-md bg-popover px-1.5 py-0.5 font-mono text-[0.6875rem] whitespace-nowrap text-popover-foreground tabular-nums opacity-0 shadow-sm ring-(length:--hairline) ring-foreground/10 transition-[opacity,scale] duration-150 ease-out-quint group-focus-visible/resizable-handle:scale-100 group-focus-visible/resizable-handle:opacity-100 group-aria-[orientation=horizontal]/resizable-handle:-translate-x-[calc(50%+3rem)] group-aria-[orientation=horizontal]/resizable-handle:-translate-y-1/2 group-data-[separator=active]/resizable-handle:scale-100 group-data-[separator=active]/resizable-handle:opacity-100 motion-reduce:transition-none rtl:translate-x-1/2 forced-colors:border"
        >
          <span ref={valueRef} />
        </span>
      ) : null}
      {children}
    </ResizablePrimitive.Separator>
  )
}

function subscribeNothing() {
  return () => {}
}

type UseDefaultLayoutOptions = Parameters<
  typeof ResizablePrimitive.useDefaultLayout
>[0]

function useDefaultLayout(options: UseDefaultLayoutOptions) {
  const hydrated = React.useSyncExternalStore(
    subscribeNothing,
    () => true,
    () => false
  )
  const custom = options.storage
  const storage = React.useMemo<ResizablePrimitive.LayoutStorage>(
    () =>
      custom ?? {
        getItem: (key) => {
          if (!hydrated) {
            return null
          }
          try {
            return window.localStorage.getItem(key)
          } catch {
            return null
          }
        },
        setItem: (key, value) => {
          if (!hydrated) {
            return
          }
          try {
            window.localStorage.setItem(key, value)
          } catch {}
        },
      },
    [custom, hydrated]
  )
  return ResizablePrimitive.useDefaultLayout({ ...options, storage })
}

const usePanelRef = ResizablePrimitive.usePanelRef
const useGroupRef = ResizablePrimitive.useGroupRef

export {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
  useDefaultLayout,
  useGroupRef,
  usePanelRef,
}
export type { ResizableHandleProps, ResizableSizeUnit }
```

Update the import paths to match your project setup.

## Usage

```tsx
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"
```

```tsx
<ResizablePanelGroup>
  <ResizablePanel defaultSize="30%" minSize="20%">Sidebar</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel>Content</ResizablePanel>
</ResizablePanelGroup>
```

Numbers are pixels and strings are percentages, so `defaultSize="30%"` and `minSize={240}` both work. The divider stays a hairline until you reach for it. While you drag, panels follow the pointer exactly; every other change glides, including double-click resets, keyboard steps and collapsing from code.

## Composition

```text
ResizablePanelGroup
├── ResizablePanel
├── ResizableHandle
└── ResizablePanel
```

## Examples

### Collapsible

A `collapsible` panel snaps shut when dragged below its `minSize`. Toggle it from a button with `usePanelRef`; the collapse animates.

```tsx title="components/examples/resizable/collapsible.tsx"
"use client"

import * as React from "react"
import { IconLayoutSidebar } from "@tabler/icons-react"

import { Button } from "@/components/ui/button"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
  usePanelRef,
} from "@/components/ui/resizable"

export function ResizableCollapsible() {
  const sidebar = usePanelRef()
  const [collapsed, setCollapsed] = React.useState(false)

  return (
    <div className="flex h-72 w-full max-w-2xl flex-col overflow-hidden rounded-xl border">
      <div className="flex items-center gap-2 border-b p-2">
        <Button
          variant="ghost"
          size="icon-sm"
          aria-label={collapsed ? "Show sidebar" : "Hide sidebar"}
          aria-pressed={!collapsed}
          onClick={() => {
            if (sidebar.current?.isCollapsed()) {
              sidebar.current.expand()
            } else {
              sidebar.current?.collapse()
            }
          }}
        >
          <IconLayoutSidebar />
        </Button>
        <span className="text-sm text-muted-foreground">
          Drag the sidebar narrow to collapse it, or press Enter on the divider.
        </span>
      </div>
      <div className="min-h-0 flex-1">
        <ResizablePanelGroup>
          <ResizablePanel
            panelRef={sidebar}
            collapsible
            defaultSize="30%"
            minSize="20%"
            collapsedSize="0%"
            onResize={(size) => setCollapsed(size.asPercentage === 0)}
          >
            <div className="flex h-full flex-col gap-1 p-4 text-sm">
              <span className="font-medium">Inbox</span>
              <span className="text-muted-foreground">Drafts</span>
              <span className="text-muted-foreground">Archive</span>
            </div>
          </ResizablePanel>
          <ResizableHandle />
          <ResizablePanel minSize="40%">
            <div className="flex h-full items-center justify-center text-sm font-medium">
              Message
            </div>
          </ResizablePanel>
        </ResizablePanelGroup>
      </div>
    </div>
  )
}
```

### Size readout

`showSize` shows the panel before the divider while you drag or use the keyboard, in `"percent"` or `"pixels"`.

```tsx title="components/examples/resizable/size.tsx"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export function ResizableSize() {
  return (
    <div className="h-56 w-full max-w-xl overflow-hidden rounded-xl border">
      <ResizablePanelGroup>
        <ResizablePanel defaultSize="320px" minSize="160px" maxSize="70%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Canvas
          </div>
        </ResizablePanel>
        <ResizableHandle showSize="pixels" />
        <ResizablePanel minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Inspector
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Remember the layout

`useDefaultLayout` saves sizes to `localStorage` and restores them on the next visit without animating in. Give each panel an `id`.

```tsx title="components/examples/resizable/persist.tsx"
"use client"

import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
  useDefaultLayout,
} from "@/components/ui/resizable"

export function ResizablePersist() {
  const { defaultLayout, onLayoutChanged } = useDefaultLayout({
    id: "hextaui-resizable-persist",
  })

  return (
    <div className="h-56 w-full max-w-md overflow-hidden rounded-xl border">
      <ResizablePanelGroup
        defaultLayout={defaultLayout}
        onLayoutChanged={onLayoutChanged}
      >
        <ResizablePanel id="list" defaultSize="40%" minSize="20%">
          <div className="flex h-full items-center justify-center p-4 text-center text-sm font-medium">
            Resize me, then reload
          </div>
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel id="detail" minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Detail
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Vertical

`orientation="vertical"` stacks panels. Groups nest, as in the demo above.

```tsx title="components/examples/resizable/vertical.tsx"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export function ResizableVertical() {
  return (
    <div className="h-72 w-full max-w-md overflow-hidden rounded-xl border">
      <ResizablePanelGroup orientation="vertical">
        <ResizablePanel defaultSize="30%" minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Header
          </div>
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel minSize="30%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Content
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Visible grip

`withHandle` keeps the grip showing instead of revealing it on hover.

```tsx title="components/examples/resizable/handle.tsx"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export function ResizableWithHandle() {
  return (
    <div className="h-56 w-full max-w-md overflow-hidden rounded-xl border">
      <ResizablePanelGroup>
        <ResizablePanel defaultSize="40%" minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Preview
          </div>
        </ResizablePanel>
        <ResizableHandle withHandle />
        <ResizablePanel minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            Code
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Right to left

Panels and arrow keys follow the reading direction.

```tsx title="components/examples/resizable/rtl.tsx"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"

export function ResizableRtl() {
  return (
    <div
      dir="rtl"
      className="h-56 w-full max-w-md overflow-hidden rounded-xl border"
    >
      <ResizablePanelGroup>
        <ResizablePanel defaultSize="35%" minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            القائمة
          </div>
        </ResizablePanel>
        <ResizableHandle showSize />
        <ResizablePanel minSize="20%">
          <div className="flex h-full items-center justify-center text-sm font-medium">
            المحتوى
          </div>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `←` `→` `↑` `↓` | Moves the focused divider by 5% along its group's direction. |
| `Home` `End` | Moves the divider as far as the panels allow. |
| `Enter` | Collapses or expands a collapsible panel. |
| `F6` | Moves to the next divider in the group; Shift goes back. |

## Accessibility

- Each divider is a focusable `separator` that reports the size of the panel before it.
- The size readout is decorative; screen readers get the separator's own value.
- With reduced motion, sizes change instantly and the divider doesn't animate.

## API reference

### ResizablePanelGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `defaultLayout` | `Layout` | – |  |
| `onLayoutChange` | `(layout) => void` | – | Every change, including while dragging. |
| `onLayoutChanged` | `(layout, meta) => void` | – | Once a change settles. |
| `disabled` | `boolean` | `false` |  |
| `groupRef` | `Ref<GroupImperativeHandle>` | – |  |

| Attribute | Description |
| --- | --- |
| `data-slot="resizable-panel-group"` | The group. |
| `data-ready` | Set after mount; sizes animate from then on. |

### ResizablePanel

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `defaultSize` | `number \| string` | – |  |
| `minSize` | `number \| string` | – |  |
| `maxSize` | `number \| string` | – |  |
| `collapsible` | `boolean` | `false` |  |
| `collapsedSize` | `number \| string` | `0` |  |
| `onResize` | `(size, id, previous) => void` | – |  |
| `panelRef` | `Ref<PanelImperativeHandle>` | – |  |
| `id` | `string` | – | Needed to remember layouts. |

### ResizableHandle

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `withHandle` | `boolean` | `false` | Always show the grip. |
| `showSize` | `boolean \| "percent" \| "pixels"` | `false` |  |
| `disableDoubleClick` | `boolean` | `false` | Stop double-click from resetting the panels. |
| `disabled` | `boolean` | `false` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="resizable-handle"` | The divider. |
| `data-separator` | "inactive", "hover", "active", "focus" or "disabled". |
| `data-slot="resizable-handle-grip"` | The grip pill. |

### Hooks

`usePanelRef` and `useGroupRef` give you `collapse`, `expand`, `resize` and `setLayout`. `useDefaultLayout` persists a group's layout.

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
