panes side by side or stacked, handles between, sizes as shares of the room
npx shadcn add @ag/resizableAlready installed? Preview an update
npx shadcn add @ag/resizable --dry-runYour 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 bystep, Home and End as far as it goes, Enter or a double-click puts it back where the default layout has it.layoutandonLayouthold the layout outside. The handle is the separator's line,withHandleadds a grip, plain or glass.lib/resizable-coreis the arithmetic.
also called split pane, splitter
shelf controls
uses key-router, material, separator
used by components
the demo above, verbatim
"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
"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>
)
}// 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
}