Draggable split panels with keyboard resizing, nested horizontal and vertical groups, collapsible panels, and a grip that appears on hover and focus.
import { FileCode2, Folder, TerminalSquare } from "lucide-react";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ballmac/resizable";
const files = ["app/page.tsx", "app/layout.tsx", "lib/utils.ts", "package.json"];
export default function ResizableDemo() {
return (
<ResizablePanelGroup direction="horizontal" className="h-80 w-full max-w-xl overflow-hidden rounded-xl border bg-card shadow-sm">
<ResizablePanel defaultSize={30} minSize={20} maxSize={45}>
<div className="h-full p-3">
<p className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<Folder aria-hidden="true" className="size-3.5" /> Explorer
</p>
<ul className="grid gap-0.5 text-sm">
{files.map((f, i) => (
<li key={f} className={`truncate rounded-md px-2 py-1 ${i === 0 ? "bg-accent font-medium" : "text-muted-foreground"}`}>{f}</li>
))}
</ul>
</div>
</ResizablePanel>
<ResizableHandle label="Resize explorer" withHandle />
<ResizablePanel defaultSize={70}>
<ResizablePanelGroup direction="vertical">
<ResizablePanel defaultSize={58} minSize={30}>
<div className="h-full p-3">
<p className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground">
<FileCode2 aria-hidden="true" className="size-3.5" /> app/page.tsx
</p>
<pre className="font-mono text-xs leading-relaxed text-muted-foreground">
{`export default function Page() {
return <main>Hello, Acme</main>
}`}
</pre>
</div>
</ResizablePanel>
<ResizableHandle label="Resize terminal" withHandle />
<ResizablePanel defaultSize={42} minSize={15}>
<div className="h-full bg-muted/40 p-3 font-mono text-xs text-muted-foreground">
<p className="mb-1 flex items-center gap-2 font-sans font-medium">
<TerminalSquare aria-hidden="true" className="size-3.5" /> Terminal
</p>
<p>$ pnpm dev</p>
<p>ready on http://localhost:3000</p>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/resizableInstall the dependencies.
$ pnpm add react-resizable-panels@^3 lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/resizable.tsx// Ballmac UI: Resizable. https://ui.ballmac.com/components/resizable // Based on shadcn/ui Resizable (MIT, Copyright (c) 2023 shadcn) on react-resizable-panels (MIT, Copyright (c) 2023 Brian Vaughn), adding a labelled separator, a larger hit area, a grip that appears on hover and focus, and a visible keyboard focus ring. "use client"; import * as React from "react"; import { GripVertical } from "lucide-react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; type ResizablePanelGroupProps = React.ComponentProps<typeof PanelGroup>; /** Container for panels and handles. `direction` is `"horizontal"` (side by side) or `"vertical"` (stacked). Pass `autoSaveId` to remember sizes. */ function ResizablePanelGroup({ className, ...props }: ResizablePanelGroupProps) { return ( <PanelGroup data-slot="resizable-panel-group" className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)} {...props} /> ); } type ResizablePanelProps = React.ComponentProps<typeof Panel>; /** A resizable region. Sizes are percentages: `defaultSize`, `minSize`, `maxSize`, and `collapsible` with `collapsedSize`. */ function ResizablePanel({ className, ...props }: ResizablePanelProps) { return <Panel data-slot="resizable-panel" className={cn("min-w-0", className)} {...props} />; } type ResizableHandleProps = React.ComponentProps<typeof PanelResizeHandle> & { /** Show a grip icon in the middle of the handle at all times (otherwise it appears on hover and focus). */ withHandle?: boolean; /** Accessible name of the separator, for example "Resize sidebar". */ label?: string; }; /** The draggable divider. Focus it and use the arrow keys, Home and End to resize; Enter collapses a collapsible neighbor. */ function ResizableHandle({ withHandle = false, label, className, ...props }: ResizableHandleProps) { const msg = useMessages() label ??= msg("resizable.label", "Resize panels") return ( <PanelResizeHandle data-slot="resizable-handle" aria-label={label} className={cn( "group/handle relative flex w-px shrink-0 items-center justify-center bg-border outline-none transition-colors", "after:absolute after:inset-y-0 after:left-1/2 after:w-3 after:-translate-x-1/2", "hover:bg-ring/60 data-[resize-handle-state=drag]:bg-ring focus-visible:bg-ring focus-visible:ring-[3px] focus-visible:ring-ring/50", "data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full", "data-[panel-group-direction=vertical]:after:inset-x-0 data-[panel-group-direction=vertical]:after:top-1/2 data-[panel-group-direction=vertical]:after:h-3 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0", "[&[data-panel-group-direction=vertical]>div]:rotate-90", className, )} {...props} > <div aria-hidden="true" className={cn( "z-10 flex h-5 w-3.5 items-center justify-center rounded-sm border bg-background text-muted-foreground shadow-xs transition-opacity motion-reduce:transition-none", withHandle ? "opacity-100" : "opacity-0 group-hover/handle:opacity-100 group-focus-visible/handle:opacity-100 group-data-[resize-handle-state=drag]/handle:opacity-100", )} > <GripVertical className="size-3" /> </div> </PanelResizeHandle> ); } export { ResizablePanelGroup, ResizablePanel, ResizableHandle, type ResizablePanelGroupProps, type ResizablePanelProps, type ResizableHandleProps, };Update the import paths to match your project setup.
Usage
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ballmac/resizable"The full example is in the Code tab above.
Examples
Collapsible panel
"use client";
import * as React from "react";
import type { ImperativePanelHandle } from "react-resizable-panels";
import { Button } from "@/components/ballmac/button";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ballmac/resizable";
export default function ResizableStates() {
const panel = React.useRef<ImperativePanelHandle>(null);
const [collapsed, setCollapsed] = React.useState(false);
return (
<div className="grid w-full max-w-lg gap-3">
<Button
variant="outline"
size="sm"
className="w-fit"
aria-pressed={collapsed}
onClick={() => (collapsed ? panel.current?.expand() : panel.current?.collapse())}
>
{collapsed ? "Show inbox" : "Hide inbox"}
</Button>
<ResizablePanelGroup direction="horizontal" className="h-40 overflow-hidden rounded-xl border bg-card">
<ResizablePanel ref={panel} defaultSize={35} minSize={20} collapsible collapsedSize={0} onCollapse={() => setCollapsed(true)} onExpand={() => setCollapsed(false)}>
<div className="h-full p-3 text-sm">
<p className="font-medium">Inbox</p>
<p className="text-muted-foreground">12 unread</p>
</div>
</ResizablePanel>
<ResizableHandle label="Resize inbox" withHandle />
<ResizablePanel defaultSize={65}>
<div className="h-full p-3 text-sm text-muted-foreground">Drag the handle, or focus it and use the arrow keys. Drag past the minimum to collapse.</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
withHandleShow a grip icon in the middle of the handle at all times (otherwise it appears on hover and focus). | boolean | false |
labelAccessible name of the separator, for example "Resize sidebar". | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| ArrowLeft / ArrowRight (ArrowUp / ArrowDown) | Resize by 10% steps from the focused handle |
| Home / End | Move to minimum or maximum |
| Enter | Collapse or expand a collapsible neighbor |
Use with AI
ResizablePanelGroup with ResizablePanel children separated by ResizableHandle. Sizes are percentages. Give each handle a label. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Resizable (@ballmac/resizable) to this project with the shadcn MCP, then use it where it fits.
Use it for
- IDE, email and file-manager layouts
- User-adjustable sidebars and consoles
Not for
- Fixed layouts; use plain CSS grid
- App navigation; use sidebar
Registry JSON: https://ui.ballmac.com/r/resizable.json
Credits
Based on shadcn/ui Resizable, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Scroll Area
A focusable, named scroll region with theme-aware custom thumb and native scrolling behavior for long lists and documents.
Separator
A semantic or decorative divider with horizontal, vertical, and centered-label layouts.
App Shell
The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.
Aspect Ratio
A CSS-native ratio frame that reserves space for media and safely handles invalid ratio values.