# Resizable

Panels separated by draggable handles that let users resize sections of a layout.

Resizable is an open-source component from suiss/ui for React. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/components/resizable

**React**

**Example**

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

function Pane({ label }: { label: string }) {
  return (
    <div className="flex h-full items-center justify-center p-6 text-sm font-medium">
      {label}
    </div>
  )
}

export default function ResizableDemo() {
  return (
    <div className="h-80 w-[36rem] overflow-hidden rounded-2xl border">
      <ResizablePanelGroup orientation="horizontal">
        <ResizablePanel defaultSize="50%">
          <Pane label="Inbox" />
        </ResizablePanel>
        <ResizableHandle />
        <ResizablePanel defaultSize="50%">
          <Pane label="Message" />
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

Resizable wraps react-resizable-panels. `ResizablePanelGroup` lays out its children in a row or column, alternating `ResizablePanel` and `ResizableHandle`. Drag a handle, or focus it and use the keyboard, to change the size of the panels on either side. Groups can be nested to build multi-pane layouts such as an editor with a sidebar and a terminal.

The `cards` variant renders each panel as a rounded card with a pill-shaped grip between them. Use Resizable for user-adjustable layouts; use Sidebar for a standard collapsible app navigation.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/resizable
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add resizable
```

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install react-resizable-panels
```

2. Copy the source into `components/ui/resizable.tsx`:

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

import * as React from "react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"

function animateJumps(
  group: HTMLElement | null,
  previous: ResizablePrimitive.Layout | null,
  next: ResizablePrimitive.Layout
) {
  if (!group || !previous) return
  if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
  const jumped = Object.entries(next).some(
    ([id, size]) => Math.abs(size - (previous[id] ?? size)) > 8
  )
  if (!jumped) return
  for (const [id, to] of Object.entries(next)) {
    const panel = Array.from(group.children).find(
      (child): child is HTMLElement =>
        child instanceof HTMLElement && child.id === id
    )
    const from = previous[id]
    if (!panel || from === undefined) continue
    for (const animation of panel.getAnimations()) animation.cancel()
    panel.animate(
      [
        { flexGrow: String(from), opacity: from < 1 ? 0 : 1 },
        { flexGrow: String(to), opacity: to < 1 ? 0 : 1 },
      ],
      { duration: 500, easing: "cubic-bezier(0.45, 0, 0.2, 1)" }
    )
  }
}

function markCollapsed(
  group: HTMLElement | null,
  layout: ResizablePrimitive.Layout
) {
  if (!group) return
  for (const handle of group.querySelectorAll<HTMLElement>(
    ":scope > [data-slot=resizable-handle]"
  )) {
    const before = handle.previousElementSibling?.id ?? ""
    const after = handle.nextElementSibling?.id ?? ""
    if ((layout[before] ?? 1) < 0.5) handle.dataset.collapsed = "start"
    else if ((layout[after] ?? 1) < 0.5) handle.dataset.collapsed = "end"
    else delete handle.dataset.collapsed
  }
}

function ResizablePanelGroup({
  className,
  variant = "default",
  onLayoutChange,
  elementRef,
  ...props
}: ResizablePrimitive.GroupProps & {
  variant?: "default" | "cards"
}) {
  const layout = React.useRef<ResizablePrimitive.Layout | null>(null)
  const group = React.useRef<HTMLDivElement | null>(null)

  return (
    <ResizablePrimitive.Group
      data-slot="resizable-panel-group"
      data-variant={variant}
      className={cn(
        "group/resizable flex h-full w-full data-[variant=cards]:*:data-[slot=resizable-panel]:rounded-2xl data-[variant=cards]:*:data-[slot=resizable-panel]:bg-muted",
        className
      )}
      elementRef={(node: HTMLDivElement | null) => {
        group.current = node
        if (typeof elementRef === "function") elementRef(node)
        else if (elementRef) elementRef.current = node
      }}
      onLayoutChange={(next) => {
        animateJumps(group.current, layout.current, next)
        markCollapsed(group.current, next)
        layout.current = next
        onLayoutChange?.(next)
      }}
      {...props}
    />
  )
}

function ResizablePanel({
  className,
  onResize,
  ...props
}: ResizablePrimitive.PanelProps) {
  const [empty, setEmpty] = React.useState(false)

  return (
    <ResizablePrimitive.Panel
      data-slot="resizable-panel"
      data-empty={empty ? "" : undefined}
      inert={empty}
      className={cn("overflow-hidden!", className)}
      onResize={(size, id, previous) => {
        setEmpty(size.inPixels < 1)
        onResize?.(size, id, previous)
      }}
      {...props}
    />
  )
}

function ResizableHandle({
  withHandle,
  className,
  ...props
}: ResizablePrimitive.SeparatorProps & {
  withHandle?: boolean
}) {
  return (
    <ResizablePrimitive.Separator
      data-slot="resizable-handle"
      className={cn(
        "group/resizable-handle relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:inset-s-1/2 after:w-1 after:-translate-x-1/2 rtl:after:translate-x-1/2 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:inset-s-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 rtl:aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
        "group-data-[variant=cards]/resizable:w-3.5 group-data-[variant=cards]/resizable:rounded-full group-data-[variant=cards]/resizable:bg-transparent group-data-[variant=cards]/resizable:aria-[orientation=horizontal]:h-3.5 group-data-[variant=cards]/resizable:aria-[orientation=horizontal]:w-full",
        "transition-[width,height,background-color] duration-500 ease-[cubic-bezier(0.45,0,0.2,1)] data-collapsed:bg-transparent aria-[orientation=vertical]:data-collapsed:after:w-4 aria-[orientation=horizontal]:data-collapsed:after:h-4 group-data-[variant=cards]/resizable:aria-[orientation=vertical]:data-collapsed:w-0 group-data-[variant=cards]/resizable:aria-[orientation=horizontal]:data-collapsed:h-0 motion-reduce:transition-none",
        className
      )}
      {...props}
    >
      <div
        data-with-handle={withHandle ? "" : undefined}
        className="z-10 hidden h-6 w-1 shrink-0 rounded-lg bg-border transition-[background-color,opacity,translate] duration-200 data-with-handle:flex group-data-collapsed/resizable-handle:flex group-data-collapsed/resizable-handle:opacity-0 group-data-collapsed/resizable-handle:group-hover/resizable-handle:opacity-100 group-data-collapsed/resizable-handle:group-data-[separator=hover]/resizable-handle:opacity-100 group-data-collapsed/resizable-handle:group-data-[separator=active]/resizable-handle:opacity-100 group-aria-[orientation=vertical]/resizable-handle:group-data-[collapsed=start]/resizable-handle:translate-x-1.5 group-aria-[orientation=vertical]/resizable-handle:group-data-[collapsed=end]/resizable-handle:-translate-x-1.5 rtl:group-aria-[orientation=vertical]/resizable-handle:group-data-[collapsed=start]/resizable-handle:-translate-x-1.5 rtl:group-aria-[orientation=vertical]/resizable-handle:group-data-[collapsed=end]/resizable-handle:translate-x-1.5 group-aria-[orientation=horizontal]/resizable-handle:group-data-[collapsed=start]/resizable-handle:translate-y-1.5 group-aria-[orientation=horizontal]/resizable-handle:group-data-[collapsed=end]/resizable-handle:-translate-y-1.5 group-data-[variant=cards]/resizable:flex group-data-[variant=cards]/resizable:h-10 group-data-[variant=cards]/resizable:rounded-full group-data-[variant=cards]/resizable:bg-border group-data-[variant=cards]/resizable:group-data-[separator=active]/resizable-handle:bg-muted-foreground group-data-[variant=cards]/resizable:group-data-[separator=hover]/resizable-handle:bg-muted-foreground"
      />
    </ResizablePrimitive.Separator>
  )
}

export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
```

3. Update the import paths to match your project.

## Usage

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

```tsx
<ResizablePanelGroup orientation="horizontal">
  <ResizablePanel defaultSize="50%">
    <Pane label="Inbox" />
  </ResizablePanel>
  <ResizableHandle />
  <ResizablePanel defaultSize="50%">
    <Pane label="Message" />
  </ResizablePanel>
</ResizablePanelGroup>
```

Set `orientation` on the group to `horizontal` (default) or `vertical`. Size panels with `defaultSize`, `minSize` and `maxSize`, for example `"30%"`. Make a panel `collapsible` with a `collapsedSize` to let it collapse when dragged past its minimum.

Use `onLayoutChange` on the group to read the current sizes, keyed by each panel's `id`.

## Examples

### With handle

A sidebar and content split with a visible grip and a minimum sidebar size.

**Example**

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

function Pane({ label }: { label: string }) {
  return (
    <div className="flex h-full items-center justify-center p-6 text-sm font-medium">
      {label}
    </div>
  )
}

export default function ResizableWithHandle() {
  return (
    <div className="h-80 w-[36rem] overflow-hidden rounded-2xl border">
      <ResizablePanelGroup orientation="horizontal">
        <ResizablePanel defaultSize="30%" minSize="20%">
          <Pane label="Sidebar" />
        </ResizablePanel>
        <ResizableHandle withHandle />
        <ResizablePanel defaultSize="70%">
          <Pane label="Content" />
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Vertical

Panels stacked vertically and resized with the up and down arrow keys.

**Example**

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

function Pane({ label }: { label: string }) {
  return (
    <div className="flex h-full items-center justify-center p-6 text-sm font-medium">
      {label}
    </div>
  )
}

export default function ResizableVertical() {
  return (
    <div className="h-80 w-[36rem] overflow-hidden rounded-2xl border">
      <ResizablePanelGroup orientation="vertical">
        <ResizablePanel defaultSize="30%" minSize="20%">
          <Pane label="Sidebar" />
        </ResizablePanel>
        <ResizableHandle withHandle />
        <ResizablePanel defaultSize="70%">
          <Pane label="Content" />
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Nested

A vertical group nested inside a horizontal group to create three panes.

**Example**

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

function Pane({ label }: { label: string }) {
  return (
    <div className="flex h-full items-center justify-center p-6 text-sm font-medium">
      {label}
    </div>
  )
}

export default function ResizableNested() {
  return (
    <div className="h-80 w-[36rem] overflow-hidden rounded-2xl border">
      <ResizablePanelGroup orientation="horizontal">
        <ResizablePanel defaultSize="30%" minSize="20%">
          <Pane label="Files" />
        </ResizablePanel>
        <ResizableHandle withHandle />
        <ResizablePanel defaultSize="70%">
          <ResizablePanelGroup orientation="vertical">
            <ResizablePanel defaultSize="65%">
              <Pane label="Editor" />
            </ResizablePanel>
            <ResizableHandle withHandle />
            <ResizablePanel defaultSize="35%">
              <Pane label="Terminal" />
            </ResizablePanel>
          </ResizablePanelGroup>
        </ResizablePanel>
      </ResizablePanelGroup>
    </div>
  )
}
```

### Cards

The `cards` variant with two collapsible panels that report their share of the space.

**Example**

```tsx
import { CashRegisterIcon, CookingPotIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"
import { ScrollArea } from "@/components/ui/scroll-area"

function CardPane({
  icon,
  label,
  size,
}: {
  icon: React.ReactNode
  label: string
  size: number | undefined
}) {
  return (
    <div className="size-full p-2">
      <ScrollArea className="size-full">
        <div className="flex min-h-full flex-col items-center justify-center gap-3 p-3 text-center">
          <span className="flex size-13 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground [&_svg]:size-6">
            {icon}
          </span>
          <div className="flex flex-col gap-0.5">
            <p className="text-sm font-semibold">{label}</p>
            <p className="text-sm text-muted-foreground tabular-nums">
              {size === undefined ? "" : `${Math.round(size)}%`}
            </p>
          </div>
        </div>
      </ScrollArea>
    </div>
  )
}

function CardsExample(props: React.ComponentProps<typeof ResizablePanelGroup>) {
  const [layout, setLayout] = React.useState<Record<string, number>>({})

  return (
    <ResizablePanelGroup {...props} variant="cards" onLayoutChange={setLayout}>
      <ResizablePanel
        id="pos"
        defaultSize="50%"
        minSize="25%"
        collapsible
        collapsedSize="0%"
        collapsedThreshold="1%"
      >
        <CardPane
          icon={
            <CashRegisterIcon />
          }
          label="Point of sale"
          size={layout.pos}
        />
      </ResizablePanel>
      <ResizableHandle />
      <ResizablePanel
        id="kitchen"
        defaultSize="50%"
        minSize="25%"
        collapsible
        collapsedSize="0%"
        collapsedThreshold="1%"
      >
        <CardPane
          icon={
            <CookingPotIcon />
          }
          label="Kitchen display"
          size={layout.kitchen}
        />
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

export default function ResizableCards() {
  return (
    <div className="h-80 w-[36rem]">
      <CardsExample orientation="horizontal" />
    </div>
  )
}
```

### Cards vertical

The `cards` variant in a vertical orientation.

**Example**

```tsx
import { CashRegisterIcon, CookingPotIcon } from "@phosphor-icons/react"
import * as React from "react"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/components/ui/resizable"
import { ScrollArea } from "@/components/ui/scroll-area"

function CardPane({
  icon,
  label,
  size,
}: {
  icon: React.ReactNode
  label: string
  size: number | undefined
}) {
  return (
    <div className="size-full p-2">
      <ScrollArea className="size-full">
        <div className="flex min-h-full flex-col items-center justify-center gap-3 p-3 text-center">
          <span className="flex size-13 shrink-0 items-center justify-center rounded-2xl bg-secondary text-secondary-foreground [&_svg]:size-6">
            {icon}
          </span>
          <div className="flex flex-col gap-0.5">
            <p className="text-sm font-semibold">{label}</p>
            <p className="text-sm text-muted-foreground tabular-nums">
              {size === undefined ? "" : `${Math.round(size)}%`}
            </p>
          </div>
        </div>
      </ScrollArea>
    </div>
  )
}

function CardsExample(props: React.ComponentProps<typeof ResizablePanelGroup>) {
  const [layout, setLayout] = React.useState<Record<string, number>>({})

  return (
    <ResizablePanelGroup {...props} variant="cards" onLayoutChange={setLayout}>
      <ResizablePanel
        id="pos"
        defaultSize="50%"
        minSize="25%"
        collapsible
        collapsedSize="0%"
        collapsedThreshold="1%"
      >
        <CardPane
          icon={
            <CashRegisterIcon />
          }
          label="Point of sale"
          size={layout.pos}
        />
      </ResizablePanel>
      <ResizableHandle />
      <ResizablePanel
        id="kitchen"
        defaultSize="50%"
        minSize="25%"
        collapsible
        collapsedSize="0%"
        collapsedThreshold="1%"
      >
        <CardPane
          icon={
            <CookingPotIcon />
          }
          label="Kitchen display"
          size={layout.kitchen}
        />
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

export default function ResizableCardsVertical() {
  return (
    <div className="h-80 w-[36rem]">
      <CardsExample orientation="vertical" />
    </div>
  )
}
```

## API Reference

- `ResizablePanelGroup`
- `ResizablePanel`
- `ResizableHandle`

### ResizablePanelGroup

The container that arranges panels and handles and tracks the layout.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "cards"` | `"default"` | Sets the visual style: `default` for flat panels with thin dividers, or `cards` for rounded panels with pill grips. |

Also accepts the props of `ResizablePrimitive.GroupProps`.

### ResizablePanel

One resizable section of the layout.

Also accepts the props of `ResizablePrimitive.PanelProps`.

### ResizableHandle

The draggable divider between two panels.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `withHandle` | `boolean` | - | Shows a visible grip on the divider. |

Also accepts the props of `ResizablePrimitive.SeparatorProps`.

## Accessibility

- Each handle has the `separator` role with `aria-valuenow` and `aria-orientation`, and is focusable with Tab.
- Arrow keys resize the adjacent panels; Home and End move the handle to its minimum and maximum.
- A panel that collapses to zero width or height becomes `inert`, so its content leaves the tab order.

## Notes

- Styling hooks: `data-slot` values `resizable-panel-group`, `resizable-panel` and `resizable-handle`. The group exposes `data-variant`, and collapsed panels expose `data-empty`.
- A handle next to a collapsed panel gets `data-collapsed` set to `start` or `end`, and its grip appears on hover.
- Large layout jumps, such as collapsing or expanding a panel, animate unless the user prefers reduced motion.
- Uses the `border`, `muted` and `muted-foreground` colors.

