← ui

resizable

ui

panes side by side or stacked, handles between, sizes as shares of the room

npx shadcn add @ag/resizable
Already installed? Preview an update
npx shadcn add @ag/resizable --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-11
  • New: panes that share a container, side by side or one above the other (ResizablePanelGroup, ResizablePanel, ResizableHandle, shadcn's names, no dependency). Sizes are shares of the room from 0 to 1, never pixels or percents (a percent screams): defaultSize, minSize, maxSize, a pane without a size taking an even part of what is left. A handle drags under a captured pointer and pushes the next pane once the nearest is at its least; focused, the arrows move it by step, Home and End as far as it goes, Enter or a double-click puts it back where the default layout has it. layout and onLayout hold the layout outside. The handle is the separator's line, withHandle adds a grip, plain or glass. lib/resizable-core is the arithmetic.
Updated 2026-10-11 · release notes

also called split pane, splitter

shelf controls

uses key-router, material, separator

used by components

the demo above, verbatim

resizable.demo.tsx
"use client"

import * as React from "react"
import { Sample } from "@/app/samples"
import { Button } from "@/registry/base-nova/ui/button"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
import {
  ResizableHandle,
  ResizablePanel,
  ResizablePanelGroup,
} from "@/registry/base-nova/ui/resizable"

// #region pane
function Pane({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex size-full items-start p-3 text-current/60 text-xs">
      {children}
    </div>
  )
}
// #endregion

// #region workspace
/** Files beside an editor over a terminal: a group inside a pane of another. */
function Workspace({ withHandle = false }: { withHandle?: boolean }) {
  return (
    <ResizablePanelGroup>
      <ResizablePanel defaultSize={0.25} minSize={0.15} maxSize={0.4}>
        <Pane>files</Pane>
      </ResizablePanel>
      <ResizableHandle withHandle={withHandle} aria-label="files and editor" />
      <ResizablePanel minSize={0.3}>
        <ResizablePanelGroup orientation="vertical">
          <ResizablePanel defaultSize={0.7} minSize={0.3}>
            <Pane>editor</Pane>
          </ResizablePanel>
          <ResizableHandle
            withHandle={withHandle}
            aria-label="editor and terminal"
          />
          <ResizablePanel minSize={0.15}>
            <Pane>terminal</Pane>
          </ResizablePanel>
        </ResizablePanelGroup>
      </ResizablePanel>
    </ResizablePanelGroup>
  )
}
// #endregion

// #region held
const EVEN = [1 / 3, 1 / 3, 1 / 3]

/** The layout held outside, set from buttons and told every change. */
function Held() {
  const [layout, setLayout] = React.useState<readonly number[]>(EVEN)
  return (
    <div className="flex flex-col gap-3">
      <div className="h-40 overflow-hidden rounded-xl ring-1 ring-foreground/10">
        <ResizablePanelGroup layout={layout} onLayout={setLayout}>
          <ResizablePanel minSize={0.1}>
            <Pane>one</Pane>
          </ResizablePanel>
          <ResizableHandle aria-label="one and two" />
          <ResizablePanel minSize={0.1}>
            <Pane>two</Pane>
          </ResizablePanel>
          <ResizableHandle aria-label="two and three" />
          <ResizablePanel minSize={0.1}>
            <Pane>three</Pane>
          </ResizablePanel>
        </ResizablePanelGroup>
      </div>
      <div className="flex flex-wrap items-center gap-2">
        <Button size="sm" variant="secondary" onClick={() => setLayout(EVEN)}>
          even
        </Button>
        <Button
          size="sm"
          variant="secondary"
          onClick={() => setLayout([0.1, 0.8, 0.1])}
        >
          focus two
        </Button>
        <span className="font-mono text-current/60 text-xs tabular-nums">
          {layout.map((share) => `${Math.round(share * 100)}%`).join(" · ")}
        </span>
      </div>
    </div>
  )
}
// #endregion

export default function ResizableDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="plain"
        title="panes in panes"
        description="drag a line, or tab to it and use the arrows, home and end; a double-click or enter puts it back."
        with="pane workspace"
      >
        <div className="h-72 overflow-hidden rounded-xl ring-1 ring-foreground/10">
          <Workspace />
        </div>
      </Sample>
      <Sample name="glass" title="in glass, with grips" with="pane workspace">
        <MaterialProvider material="glass">
          <div className="rounded-2xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6 text-white">
            <Panel className="h-72 overflow-hidden p-0">
              <Workspace withHandle />
            </Panel>
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="held"
        glance
        title="a layout held outside"
        description="shares of the room, not pixels: the panes keep them as the window changes."
        with="pane held"
      >
        <Held />
      </Sample>
    </div>
  )
}

the source, every file an install writes

resizable.tsx
"use client"

import { GripHorizontal, GripVertical } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import {
  assertLayout,
  boundaryAt,
  layoutOf,
  limitsOf,
  type PaneLimits,
  resize,
} from "@/registry/base-nova/lib/resizable-core"
import { ownedHere } from "@/registry/base-nova/ui/key-router"
import { type Material, useMaterial } from "@/registry/base-nova/ui/material"
import { glassVariants } from "@/registry/base-nova/ui/material-glass"
import { Separator } from "@/registry/base-nova/ui/separator"

// PANES THAT SHARE A CONTAINER, a handle between each two, named as shadcn names them
// so code written for shadcn composes it:
//
//   <ResizablePanelGroup orientation="horizontal">
//     <ResizablePanel defaultSize={0.25} minSize={0.15}>files</ResizablePanel>
//     <ResizableHandle withHandle />
//     <ResizablePanel>editor</ResizablePanel>
//   </ResizablePanelGroup>
//
// Sizes are SHARES of the room the panes have, 0 to 1 (lib/resizable-core): a quarter
// stays a quarter when the window grows. A pane without a `defaultSize` takes an even
// part of what is left. Dragged, a handle moves under the pointer (captured, so it
// keeps the drag wherever the pointer goes); pushed into a pane at its least, it pushes
// the next one. Focused, the arrows along it move it a `step`, Home and End take it as
// far as it goes, and Enter (or a double-click) puts it back where the default layout
// has it. The layout is the group's own, or the consumer's (`layout` with `onLayout`,
// which is told every change, a drag's included).
//
// The handle is a LEAF, not a surface: the registry's separator line (the page's
// hairline plain, a glass's lit line in glass) with room around it for the hand, drawn
// in the colour of what it divides, so it takes no scheme and reads in whatever holds
// the group. `withHandle` adds a grip at its middle: a small tab of the page's surface,
// or a sliver of glass. The group itself is layout and draws nothing.
//
// A group's children are its panels with a handle between each two, in order; nothing
// else, so the group knows its panes when it renders (on the server too: the first
// paint is the layout) instead of waiting for them to announce themselves.

export type ResizableOrientation = "horizontal" | "vertical"

interface GroupState {
  orientation: ResizableOrientation
  layout: readonly number[]
  limits: readonly PaneLimits[]
  ids: readonly string[]
  material?: Material
  dragging: number | null
  /** Moves boundary `i` by a share (the keys). */
  move: (i: number, delta: number) => void
  /** Puts boundary `i` back where the default layout has it. */
  reset: (i: number) => void
  grab: (i: number, event: React.PointerEvent<HTMLElement>) => void
  drag: (event: React.PointerEvent<HTMLElement>) => void
  release: (event: React.PointerEvent<HTMLElement>) => void
  step: number
}

const GroupContext = React.createContext<GroupState | null>(null)
/** A part's place: a pane's index, or a handle's boundary (the pane before it). */
const PlaceContext = React.createContext(-1)

function useGroup(part: string): GroupState {
  const group = React.useContext(GroupContext)
  if (!group) throw new Error(`${part}: place it inside a ResizablePanelGroup`)
  return group
}

export interface ResizablePanelGroupProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Side by side (default) or one above the other. */
  orientation?: ResizableOrientation
  /** The layout, one share per pane summing to 1, when the consumer holds it. */
  layout?: readonly number[]
  /** The layout it starts from; unset, the panes' own `defaultSize`. */
  defaultLayout?: readonly number[]
  /** Every change of the layout, a drag's moves included. */
  onLayout?: (layout: number[]) => void
  /** How far an arrow key moves a handle, a share of the room. */
  step?: number
  /** The handles' material (each handle may say its own). */
  material?: Material
  children: React.ReactNode
}

export function ResizablePanelGroup({
  orientation = "horizontal",
  layout: given,
  defaultLayout,
  onLayout,
  step = 0.05,
  material,
  className,
  children,
  ...props
}: ResizablePanelGroupProps) {
  const parts = React.Children.toArray(children)
  parts.forEach((part, k) => {
    const want = k % 2 === 0 ? ResizablePanel : ResizableHandle
    if (!React.isValidElement(part) || part.type !== want)
      throw new Error(
        "ResizablePanelGroup: its children are ResizablePanels with a ResizableHandle between each two, and nothing else",
      )
  })
  if (parts.length % 2 === 0)
    throw new Error("ResizablePanelGroup: it starts and ends with a panel")
  const panes = parts.filter(
    (_, k) => k % 2 === 0,
  ) as React.ReactElement<ResizablePanelProps>[]
  const limits = panes.map((p) => limitsOf(p.props.minSize, p.props.maxSize))
  const defaults = layoutOf(
    defaultLayout ?? panes.map((p) => p.props.defaultSize),
    limits,
  )
  const made = React.useId()
  const ids = panes.map((p, i) => p.props.id ?? `${made}-pane-${i}`)

  // The layout: the consumer's, or held here (back to the defaults when the panes
  // change in number).
  const [held, setHeld] = React.useState<readonly number[] | null>(null)
  const layout =
    given ?? (held !== null && held.length === panes.length ? held : defaults)
  assertLayout(layout, limits)
  const change = (next: number[]) => {
    setHeld(next)
    onLayout?.(next)
  }

  const root = React.useRef<HTMLDivElement>(null)
  const [dragging, setDragging] = React.useState<number | null>(null)
  const held_ = React.useRef<{
    boundary: number
    pointer: number
    from: number
    start: readonly number[]
    room: number
    sign: 1 | -1
  } | null>(null)
  const across = orientation === "horizontal"
  const along = (e: { clientX: number; clientY: number }) =>
    across ? e.clientX : e.clientY

  const state: GroupState = {
    orientation,
    layout,
    limits,
    ids,
    material,
    dragging,
    step,
    move: (i, delta) => change(resize(layout, limits, i, delta)),
    reset: (i) =>
      change(
        resize(
          layout,
          limits,
          i,
          boundaryAt(defaults, i) - boundaryAt(layout, i),
        ),
      ),
    grab: (i, event) => {
      const el = root.current
      if (event.button !== 0 || !el) return
      event.currentTarget.setPointerCapture(event.pointerId)
      // The room the panes have now, in pixels: what a pixel of travel is a share of.
      let room = 0
      for (const pane of el.querySelectorAll<HTMLElement>(
        ":scope > [data-slot=resizable-panel]",
      )) {
        const box = pane.getBoundingClientRect()
        room += across ? box.width : box.height
      }
      held_.current = {
        boundary: i,
        pointer: event.pointerId,
        from: along(event),
        start: layout,
        room,
        sign: across && getComputedStyle(el).direction === "rtl" ? -1 : 1,
      }
      setDragging(i)
    },
    drag: (event) => {
      const d = held_.current
      if (!d || d.pointer !== event.pointerId || d.room <= 0) return
      const delta = ((along(event) - d.from) * d.sign) / d.room
      change(resize(d.start, limits, d.boundary, delta))
    },
    release: (event) => {
      if (held_.current?.pointer !== event.pointerId) return
      held_.current = null
      setDragging(null)
    },
  }

  return (
    <GroupContext.Provider value={state}>
      <div
        ref={root}
        data-slot="resizable"
        data-orientation={orientation}
        data-resizing={dragging !== null || undefined}
        className={cn(
          "flex size-full data-resizing:select-none",
          across ? "flex-row" : "flex-col",
          className,
        )}
        {...props}
      >
        {parts.map((part, k) => (
          <PlaceContext.Provider
            key={(part as React.ReactElement).key ?? k}
            value={k >> 1}
          >
            {part}
          </PlaceContext.Provider>
        ))}
      </div>
    </GroupContext.Provider>
  )
}

export interface ResizablePanelProps extends React.ComponentProps<"div"> {
  /** The share it starts with, 0 to 1. Unset, an even part of what is left. */
  defaultSize?: number
  /** The least and the most share it may have, 0 to 1. */
  minSize?: number
  maxSize?: number
}

/** One pane, its share of the room as its flex. It clips what it holds; a pane that
 *  scrolls says so (`overflow-auto`). */
export function ResizablePanel({
  defaultSize: _defaultSize,
  minSize: _minSize,
  maxSize: _maxSize,
  id: _id,
  className,
  style,
  ...props
}: ResizablePanelProps) {
  const group = useGroup("ResizablePanel")
  const i = React.useContext(PlaceContext)
  return (
    <div
      data-slot="resizable-panel"
      id={group.ids[i]}
      style={
        { "--ag-pane-share": group.layout[i], ...style } as React.CSSProperties
      }
      className={cn(
        "min-h-0 min-w-0 shrink grow-(--ag-pane-share) basis-0 overflow-hidden",
        className,
      )}
      {...props}
    />
  )
}

export interface ResizableHandleProps
  extends Omit<
    React.ComponentProps<typeof Separator>,
    "orientation" | "children"
  > {
  /** A grip at its middle, for a handle that should be seen as one. */
  withHandle?: boolean
}

/** The grip's face: a tab of the page's surface, or a sliver of glass. */
const GRIP: Record<Material, string> = {
  plain:
    "rounded-sm bg-background text-foreground/50 ring-1 ring-foreground/15 scheme-dark:bg-foreground/10",
  glass: cn(
    glassVariants({ shape: "surface" }),
    // A pill 12 px across: its radius 6 px.
    "text-current/70 [--glass-radius:6px]",
  ),
}

/** The line between two panes, and what the hand and the keys move. */
export function ResizableHandle({
  withHandle = false,
  material,
  className,
  onKeyDown,
  onDoubleClick,
  ...props
}: ResizableHandleProps) {
  const group = useGroup("ResizableHandle")
  const i = React.useContext(PlaceContext)
  const m = useMaterial(material ?? group.material)
  const across = group.orientation === "horizontal"
  const limits = group.limits[i] as PaneLimits
  const percent = (share: number) => Math.round(share * 100)
  return (
    <Separator
      // The line runs across the way the panes are laid: down between panes side
      // by side.
      orientation={across ? "vertical" : "horizontal"}
      material={m}
      tabIndex={0}
      data-slot="resizable-handle"
      data-resizing={group.dragging === i || undefined}
      aria-controls={group.ids[i]}
      aria-valuenow={percent(group.layout[i] as number)}
      aria-valuemin={percent(limits.min)}
      aria-valuemax={percent(limits.max)}
      onPointerDown={(e) => group.grab(i, e)}
      onPointerMove={group.drag}
      onPointerUp={group.release}
      onPointerCancel={group.release}
      onLostPointerCapture={group.release}
      onDoubleClick={(e) => {
        onDoubleClick?.(e)
        group.reset(i)
      }}
      onKeyDown={(e) => {
        onKeyDown?.(e)
        if (e.metaKey || e.ctrlKey || e.altKey) return
        if (ownedHere(e.nativeEvent, e.currentTarget)) return
        const rtl =
          across && getComputedStyle(e.currentTarget).direction === "rtl"
        const back = across ? (rtl ? "ArrowRight" : "ArrowLeft") : "ArrowUp"
        const ahead = across ? (rtl ? "ArrowLeft" : "ArrowRight") : "ArrowDown"
        const delta =
          e.key === back
            ? -group.step
            : e.key === ahead
              ? group.step
              : e.key === "Home"
                ? -Infinity
                : e.key === "End"
                  ? Infinity
                  : null
        if (e.key === "Enter") group.reset(i)
        else if (delta !== null) group.move(i, delta)
        else return
        e.preventDefault()
      }}
      className={cn(
        "relative z-10 flex touch-none items-center justify-center",
        // The hand's room round the hairline, and the line it lights while it is
        // under the hand, held or focused.
        "after:absolute before:pointer-events-none before:absolute before:transition-colors before:duration-150 hover:before:bg-current/30 focus-visible:before:bg-current/40 data-resizing:before:bg-current/45 motion-reduce:before:transition-none",
        across
          ? "cursor-col-resize before:inset-y-0 before:-inset-x-px after:inset-y-0 after:-inset-x-1"
          : "cursor-row-resize before:inset-x-0 before:-inset-y-px after:inset-x-0 after:-inset-y-1",
        className,
      )}
      {...props}
    >
      {withHandle && (
        <span
          aria-hidden
          data-slot="resizable-grip"
          className={cn(
            "z-10 grid shrink-0 place-items-center [&_svg]:size-2.5",
            across ? "h-6 w-3" : "h-3 w-6",
            GRIP[m],
          )}
        >
          {across ? <GripVertical /> : <GripHorizontal />}
        </span>
      )}
    </Separator>
  )
}
resizable-core.ts
// Panes that share a container, framework-free: the arithmetic of `resizable`.
//
// A LAYOUT is each pane's share of the room the panes have (the container less its
// handles), in order, summing to 1. A share is the unit a split is felt in: a sidebar
// a quarter wide stays a quarter when the window grows, where a pixel width would turn
// it into a sliver or a slab. Each pane may hold a least and a most share of its own.
//
// A handle sits on a BOUNDARY, between pane `i` and pane `i + 1`. Moving it grows the
// panes on one side and shrinks the other's: the nearest pane first, and once it is at
// its limit the next one beyond it, so a handle dragged into a pane already at its
// least pushes the one behind it (the way a desktop's split views do). A move that no
// pane on one side can take any more of stops there.

/** The least and the most share one pane may have. */
export interface PaneLimits {
  min: number
  max: number
}

/** A share is never off by more than this: a sum of floats is 1 within it. */
const EPSILON = 1e-9

/** A pane's limits, each a share between 0 and 1. A percent written for a share (50
 *  for half) screams here. */
export function limitsOf(min = 0, max = 1): PaneLimits {
  if (!(min >= 0 && max <= 1 && min <= max))
    throw new Error(
      `resizable: a pane's limits are shares from 0 to 1, least first (got ${min}, ${max})`,
    )
  return { min, max }
}

const sum = (xs: readonly number[]) => xs.reduce((a, b) => a + b, 0)

/** Screams unless `layout` is one share per pane, each within its limits, summing to
 *  1, and the limits can be met at all. */
export function assertLayout(
  layout: readonly number[],
  limits: readonly PaneLimits[],
): void {
  if (layout.length !== limits.length)
    throw new Error(
      `resizable: ${layout.length} shares for ${limits.length} panes`,
    )
  if (Math.abs(sum(layout) - 1) > 1e-6)
    throw new Error(
      `resizable: the shares sum to ${sum(layout)}, not 1 (${layout.join(", ")})`,
    )
  layout.forEach((share, i) => {
    const l = limits[i] as PaneLimits
    if (share < l.min - 1e-6 || share > l.max + 1e-6)
      throw new Error(
        `resizable: pane ${i} at ${share}, outside its limits ${l.min} to ${l.max}`,
      )
  })
}

/** Screams unless the panes' limits leave a layout possible: their least shares fit
 *  in the room and their most shares fill it. */
function assertFeasible(limits: readonly PaneLimits[]): void {
  if (limits.length === 0) throw new Error("resizable: no panes")
  const least = sum(limits.map((l) => l.min))
  const most = sum(limits.map((l) => l.max))
  if (least > 1 + EPSILON || most < 1 - EPSILON)
    throw new Error(
      `resizable: the panes' limits cannot share the room (least ${least}, most ${most})`,
    )
}

/** `layout` brought within the limits and to a sum of 1: each share held to its pane's
 *  limits, then what is over or short spread evenly over the panes that can still
 *  take it. */
export function fit(
  layout: readonly number[],
  limits: readonly PaneLimits[],
): number[] {
  assertFeasible(limits)
  const out = layout.map((share, i) => {
    const l = limits[i] as PaneLimits
    return Math.min(l.max, Math.max(l.min, share))
  })
  for (let round = 0; round <= out.length; round++) {
    const short = 1 - sum(out)
    if (Math.abs(short) <= EPSILON) break
    const open = out
      .map((_, i) => i)
      .filter((i) => {
        const l = limits[i] as PaneLimits
        const share = out[i] as number
        return short > 0 ? share < l.max - EPSILON : share > l.min + EPSILON
      })
    const each = short / open.length
    for (const i of open) {
      const l = limits[i] as PaneLimits
      out[i] = Math.min(l.max, Math.max(l.min, (out[i] as number) + each))
    }
  }
  assertLayout(out, limits)
  return out
}

/** The layout a group starts with: each pane's own share where it says one, the rest
 *  of the room split evenly between the panes that do not, then fitted. */
export function layoutOf(
  sizes: readonly (number | undefined)[],
  limits: readonly PaneLimits[],
): number[] {
  if (sizes.length !== limits.length)
    throw new Error(
      `resizable: ${sizes.length} sizes for ${limits.length} panes`,
    )
  const said = sizes.filter((s): s is number => s !== undefined)
  for (const s of said)
    if (!(s >= 0 && s <= 1))
      throw new Error(
        `resizable: a size is a share from 0 to 1 (got ${s}; half is 0.5, not 50)`,
      )
  const left = 1 - sum(said)
  if (left < -EPSILON)
    throw new Error(`resizable: the sizes said sum to ${sum(said)}, over 1`)
  const unsaid = sizes.length - said.length
  return fit(
    sizes.map((s) => s ?? (unsaid ? Math.max(0, left) / unsaid : 0)),
    limits,
  )
}

/** Where boundary `i` stands: the share of the room before it (panes 0 to `i`). */
export const boundaryAt = (layout: readonly number[], i: number): number =>
  sum(layout.slice(0, i + 1))

/** `layout` with boundary `i` moved by `delta` (a share of the room: positive toward
 *  the end), as far as the panes on both sides allow. The nearest pane on each side
 *  gives or takes first, then the next one beyond it. */
export function resize(
  layout: readonly number[],
  limits: readonly PaneLimits[],
  i: number,
  delta: number,
): number[] {
  if (!(i >= 0 && i < layout.length - 1))
    throw new Error(
      `resizable: no boundary ${i} between ${layout.length} panes`,
    )
  const before = Array.from({ length: i + 1 }, (_, k) => i - k)
  const after = Array.from(
    { length: layout.length - i - 1 },
    (_, k) => i + 1 + k,
  )
  // Toward the end: the panes before grow and the panes after shrink; back, the other
  // way round. Each side nearest first.
  const [growing, shrinking] = delta >= 0 ? [before, after] : [after, before]
  const room = (side: number[], grow: boolean) =>
    sum(
      side.map((k) => {
        const l = limits[k] as PaneLimits
        const share = layout[k] as number
        return grow ? l.max - share : share - l.min
      }),
    )
  const amount = Math.min(
    Math.abs(delta),
    room(growing, true),
    room(shrinking, false),
  )
  const out = [...layout]
  const spend = (side: number[], grow: boolean) => {
    let left = amount
    for (const k of side) {
      if (left <= 0) break
      const l = limits[k] as PaneLimits
      const share = out[k] as number
      const step = Math.min(left, grow ? l.max - share : share - l.min)
      out[k] = share + (grow ? step : -step)
      left -= step
    }
  }
  spend(growing, true)
  spend(shrinking, false)
  return out
}