Resizable

Accessible resizable panel groups and layouts with keyboard support.

One
Two
Three
import * as stylex from "@stylexjs/stylex"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { VStack } from "@/components/ui/stack"
import { leading, radius, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  group: {
    maxWidth: "24rem",
    minHeight: "12.5rem",
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
  },
  label: { fontSize: text.sm, lineHeight: leading.sm, fontWeight: 600 },
})

export default function ResizableDemo() {
  return (
    <ResizablePanelGroup orientation="horizontal" xstyle={styles.group}>
      <ResizablePanel defaultSize="50%">
        <VStack align="center" justify="center" padding={6} height="100%">
          <span {...stylex.props(styles.label)}>One</span>
        </VStack>
      </ResizablePanel>
      <ResizableHandle withHandle />
      <ResizablePanel defaultSize="50%">
        <ResizablePanelGroup orientation="vertical">
          <ResizablePanel defaultSize="25%">
            <VStack align="center" justify="center" padding={6} height="100%">
              <span {...stylex.props(styles.label)}>Two</span>
            </VStack>
          </ResizablePanel>
          <ResizableHandle withHandle />
          <ResizablePanel defaultSize="75%">
            <VStack align="center" justify="center" padding={6} height="100%">
              <span {...stylex.props(styles.label)}>Three</span>
            </VStack>
          </ResizablePanel>
        </ResizablePanelGroup>
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

Installation

Install the following dependencies:

pnpm add react-resizable-panels @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation. The handle also needs interop.css for its hover and drag colour.

Copy and paste the following code into your project.

components/ui/resizable.tsx
"use client"

import { createContext, use } from "react"
import * as ResizablePrimitive from "react-resizable-panels"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { color, duration, ease, radius, space } from "@/theme/tokens.stylex"

type ResizableOrientation = "horizontal" | "vertical"

const ResizableContext = createContext<ResizableOrientation>("horizontal")

const styles = stylex.create({
  handle: {
    position: "relative",
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    color: { default: color.border, ":focus-visible": color.ring },
    backgroundColor: "currentColor",
    outlineStyle: { default: "none", ":focus-visible": "solid" },
    outlineWidth: 2,
    outlineColor: color.ring,
    transitionProperty: "color",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
  },
  grip: {
    flexShrink: 0,
    borderRadius: radius.full,
    borderWidth: 1,
    borderStyle: "solid",
    borderColor: color.border,
    backgroundColor: color.background,
  },
})

const handleStyles = stylex.create({
  horizontal: { width: 1 },
  vertical: { height: 1 },
})

const gripStyles = stylex.create({
  horizontal: { width: space.s1_5, height: space.s6 },
  vertical: { width: space.s6, height: space.s1_5 },
})

const resizableStyles = {
  handle: styles.handle,
  handles: handleStyles,
  grip: styles.grip,
  grips: gripStyles,
}

type StyleProps = {
  xstyle?: StyleXStyles
  className?: string
}

type ResizablePanelGroupProps = Omit<ResizablePrimitive.GroupProps, "className"> & StyleProps

function ResizablePanelGroup({
  orientation = "horizontal",
  xstyle,
  className,
  style,
  children,
  ...props
}: ResizablePanelGroupProps) {
  const sx = stylex.props(xstyle)

  return (
    <ResizableContext value={orientation}>
      <ResizablePrimitive.Group
        data-slot="resizable-panel-group"
        data-orientation={orientation}
        orientation={orientation}
        {...sx}
        {...props}
        className={clsx(sx.className, className)}
        style={{ ...sx.style, ...style }}
      >
        {children}
      </ResizablePrimitive.Group>
    </ResizableContext>
  )
}

type ResizablePanelProps = Omit<ResizablePrimitive.PanelProps, "className"> & StyleProps

function ResizablePanel({ xstyle, className, style, ...props }: ResizablePanelProps) {
  const sx = stylex.props(xstyle)

  return (
    <ResizablePrimitive.Panel
      data-slot="resizable-panel"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
      style={{ ...sx.style, ...style }}
    />
  )
}

type ResizableHandleProps = Omit<ResizablePrimitive.SeparatorProps, "className"> &
  StyleProps & {
    withHandle?: boolean
  }

function ResizableHandle({
  withHandle = false,
  xstyle,
  className,
  style,
  ...props
}: ResizableHandleProps) {
  const orientation = use(ResizableContext)
  const sx = stylex.props(styles.handle, handleStyles[orientation], xstyle)

  return (
    <ResizablePrimitive.Separator
      data-slot="resizable-handle"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
      style={{ ...sx.style, ...style }}
    >
      {withHandle && (
        <div
          data-slot="resizable-handle-grip"
          {...stylex.props(styles.grip, gripStyles[orientation])}
        />
      )}
    </ResizablePrimitive.Separator>
  )
}

export {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
  resizableStyles,
  type ResizableOrientation,
}

Update the import paths to match your project setup.

Usage

import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
<ResizablePanelGroup orientation="horizontal">
  <ResizablePanel>One</ResizablePanel>
  <ResizableHandle />
  <ResizablePanel>Two</ResizablePanel>
</ResizablePanelGroup>

A group is a flex row or column that fills its parent. Panels split it. A handle between two panels can be dragged, takes focus and responds to the arrow keys.

ResizablePanelGroup
├── ResizablePanel
├── ResizableHandle
└── ResizablePanel

The group sets width: 100%, height: 100%, display, flex-direction and overflow inline, and an inline declaration beats any class. Give it a floor or a cap through xstyle (minHeight, maxWidth) and a fixed size through style.

Size a panel with defaultSize, minSize and maxSize. A number is pixels, a bare string is a percentage, and a string with a unit is that unit.

<ResizablePanel defaultSize="30%" minSize={200} maxSize="50%" />

defaultSize={50} is 50 pixels, not half the group. Write "50%" or "50" for the percentage.

Vertical

Use orientation="vertical" to stack the panels.

Header
Content
import * as stylex from "@stylexjs/stylex"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { VStack } from "@/components/ui/stack"
import { leading, radius, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  group: {
    maxWidth: "24rem",
    minHeight: "12.5rem",
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
  },
  label: { fontSize: text.sm, lineHeight: leading.sm, fontWeight: 600 },
})

export default function ResizableVertical() {
  return (
    <ResizablePanelGroup orientation="vertical" xstyle={styles.group}>
      <ResizablePanel defaultSize="25%">
        <VStack align="center" justify="center" padding={6} height="100%">
          <span {...stylex.props(styles.label)}>Header</span>
        </VStack>
      </ResizablePanel>
      <ResizableHandle />
      <ResizablePanel defaultSize="75%">
        <VStack align="center" justify="center" padding={6} height="100%">
          <span {...stylex.props(styles.label)}>Content</span>
        </VStack>
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

Handle

Use the withHandle prop on ResizableHandle to draw a grip on the line.

Sidebar
Content
import * as stylex from "@stylexjs/stylex"
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"
import { VStack } from "@/components/ui/stack"
import { leading, radius, text } from "@/theme/tokens.stylex"

const styles = stylex.create({
  group: {
    maxWidth: "24rem",
    minHeight: "12.5rem",
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
  },
  label: { fontSize: text.sm, lineHeight: leading.sm, fontWeight: 600 },
})

export default function ResizableHandleDemo() {
  return (
    <ResizablePanelGroup orientation="horizontal" xstyle={styles.group}>
      <ResizablePanel defaultSize="25%">
        <VStack align="center" justify="center" padding={6} height="100%">
          <span {...stylex.props(styles.label)}>Sidebar</span>
        </VStack>
      </ResizablePanel>
      <ResizableHandle withHandle />
      <ResizablePanel defaultSize="75%">
        <VStack align="center" justify="center" padding={6} height="100%">
          <span {...stylex.props(styles.label)}>Content</span>
        </VStack>
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}

API reference

Every part wraps the react-resizable-panels part of the same role and accepts its props: Group, Panel and Separator. Each part also takes xstyle and className.

ResizablePanelGroup

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"
defaultLayout{ [panelId: string]: number }—
onLayoutChanged(layout: Layout, meta: { isUserInteraction: boolean }) => void—
disabledbooleanfalse
disableCursorbooleanfalse
groupRefRef<GroupImperativeHandle>—
idstring | number—

onLayoutChanged fires once the pointer is released, so save a layout from that callback. onLayoutChange fires on every pointer move. The library sets the cursor from its own hit test for the whole page while a handle is hovered or dragged; disableCursor turns that off.

ResizablePanel

PropTypeDefault
defaultSizenumber | string—
minSizenumber | string—
maxSizenumber | string—
collapsiblebooleanfalse
collapsedSizenumber | string0
disabledbooleanfalse
onResize(size: PanelSize, id, prevSize?: PanelSize) => void—
panelRefRef<PanelImperativeHandle>—
idstring | number—

The library renders a panel as two elements: an outer flex item it sizes, and an inner box that scrolls. className, style and xstyle apply to the inner box, so a style you pass cannot override the sizing.

A collapsible panel snaps to collapsedSize when it is dragged under its minSize. panelRef reaches collapse(), expand() and isCollapsed(), so a toggle button can call them. usePanelRef from react-resizable-panels returns a ref of the right type.

ResizableHandle

PropTypeDefault
withHandlebooleanfalse
disabledbooleanfalse
disableDoubleClickbooleanfalse

Double-clicking a handle resets its panels to their default sizes unless disableDoubleClick is set. A disabled handle cannot be dragged, but its panels still move when a neighbour is resized; disable the panel too to pin it.

resizableStyles

The style maps behind the handle, exported so the same line and grip can be put on another element.

KeyDescription
handleThe line: centring flex box, currentColor fill, focus outline
handlesOne style per group orientation, the 1px width or height
gripThe flat pill: rounding, page fill and hairline
gripsOne style per group orientation, the pill's long side along the line

Data attributes

AttributeOnEffect
data-slot="resizable-panel-group"the groupTargets the group from a parent
data-orientationthe groupThe orientation prop
data-slot="resizable-panel"a panel's outer boxThe flex item the library sizes
data-slot="resizable-handle"the separatorWhere interop.css keys the hover and drag colour
data-slot="resizable-handle-grip"the gripTargets the pill
data-separatorthe separatorThe library writes "inactive", "hover", "active", "focus" or "disabled"
aria-orientationthe separatorRuns across the group, so "vertical" in a horizontal group
data-group, data-panelgroup and outer boxThe library writes them, with the id as the value
data-disableda panel's outer boxThe library writes it from disabled