Resizable
LayoutTwo-pane resizing with pointer, keyboard, and RTL support.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { Resizable } from "@/components/ui/resizable";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
export default function ResizableExample() {
const [size, setSize] = useState(40);
const [vertical, setVertical] = useState(false);
return (
<div className="w-full max-w-lg space-y-3">
<div className="flex gap-2">
<Button
size="sm"
variant={vertical ? "ghost" : "secondary"}
onClick={() => setVertical(false)}
>
Horizontal
</Button>
<Button
size="sm"
variant={vertical ? "secondary" : "ghost"}
onClick={() => setVertical(true)}
>
Vertical
</Button>
</div>
<Resizable
orientation={vertical ? "vertical" : "horizontal"}
value={size}
onValueChange={setSize}
label="Resize workspace panels"
first={
<div className="p-5">
<h3 className="text-sm font-medium">Project files</h3>
<p className="mt-2 text-xs text-muted-foreground">
Navigation and documents
</p>
</div>
}
second={
<div className="p-5">
<h3 className="text-sm font-medium">Live preview</h3>
<p className="mt-2 text-xs text-muted-foreground">
Drag the divider or focus it and use arrow keys.
</p>
</div>
}
/>
<p role="status" className="text-xs text-muted-foreground">
First panel: {Math.round(size)}%. Shift+arrow changes by 10%.
</p>
</div>
);
}Usage
Provide first and second content. value is the first pane’s percentage. The component supports exactly two panes; choose orientation and set bounds in the 0–100 range. Both panes can scroll when their contents exceed their size.
import { Resizable } from "@/components/ui/resizable";Shipped as editable source in src/components/ui/resizable.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.
Component source
The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.
src/components/ui/resizable.tsx
"use client";
import { useId, useRef, useState, type ReactNode } from "react";
import { cn } from "@dashboardpack/core/lib/utils";
export interface ResizableProps {
first: ReactNode;
second: ReactNode;
label?: string;
orientation?: "horizontal" | "vertical";
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
disabled?: boolean;
className?: string;
}
export function Resizable({
first,
second,
label = "Resize panels",
orientation = "horizontal",
value,
defaultValue = 50,
onValueChange,
min = 20,
max = 80,
disabled,
className,
}: ResizableProps) {
const [internal, setInternal] = useState(defaultValue);
const root = useRef<HTMLDivElement>(null);
const id = useId();
const lower = Math.max(0, Math.min(100, min));
const upper = Math.max(lower, Math.min(100, max));
const clamp = (next: number) =>
Math.max(lower, Math.min(upper, Number.isFinite(next) ? next : 50));
const current = clamp(value ?? internal);
const update = (next: number) => {
const bounded = clamp(next);
setInternal(bounded);
onValueChange?.(bounded);
};
const vertical = orientation === "vertical";
return (
<div
ref={root}
className={cn(
"grid h-64 w-full min-w-0 overflow-hidden rounded-xl border bg-background",
className,
)}
style={
vertical
? { gridTemplateRows: `${current}fr 8px ${100 - current}fr` }
: { gridTemplateColumns: `${current}fr 8px ${100 - current}fr` }
}
>
<div id={`${id}-first`} className="min-h-0 min-w-0 overflow-auto">
{first}
</div>
<div
role="separator"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-controls={`${id}-first ${id}-second`}
aria-orientation={vertical ? "horizontal" : "vertical"}
aria-valuemin={lower}
aria-valuemax={upper}
aria-valuenow={Math.round(current)}
aria-valuetext={`${Math.round(current)} percent`}
aria-disabled={disabled || undefined}
className={cn(
"relative touch-none bg-muted outline-none focus-visible:bg-primary/30 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
disabled
? "cursor-default"
: vertical
? "cursor-row-resize"
: "cursor-col-resize",
)}
onKeyDown={(event) => {
if (disabled) return;
const rtl =
root.current && getComputedStyle(root.current).direction === "rtl";
const increase = vertical
? "ArrowDown"
: rtl
? "ArrowLeft"
: "ArrowRight";
const decrease = vertical
? "ArrowUp"
: rtl
? "ArrowRight"
: "ArrowLeft";
if ([increase, decrease, "Home", "End"].includes(event.key)) {
event.preventDefault();
update(
event.key === "Home"
? lower
: event.key === "End"
? upper
: current +
(event.key === increase ? 1 : -1) *
(event.shiftKey ? 10 : 2),
);
}
}}
onPointerDown={(event) => {
if (!disabled && event.button === 0) {
event.preventDefault();
event.currentTarget.focus();
event.currentTarget.setPointerCapture(event.pointerId);
}
}}
onPointerMove={(event) => {
if (
disabled ||
!event.currentTarget.hasPointerCapture(event.pointerId) ||
!root.current
)
return;
const rect = root.current.getBoundingClientRect();
const extent = (vertical ? rect.height : rect.width) - 8;
if (extent <= 0) return;
let distance = vertical
? event.clientY - rect.top
: event.clientX - rect.left;
if (!vertical && getComputedStyle(root.current).direction === "rtl")
distance = rect.right - event.clientX;
update(((distance - 4) / extent) * 100);
}}
onPointerUp={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
}}
/>
<div id={`${id}-second`} className="min-h-0 min-w-0 overflow-auto">
{second}
</div>
</div>
);
}API reference
Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.
| Prop | Type / Default | Description |
|---|---|---|
| first / second | ReactNodeDefault: required | Content for each pane. |
| value / defaultValue | numberDefault: 50 | Controlled or initial first-pane percentage. |
| onValueChange | (value: number) => voidDefault: — | Receives bounded percentages. |
| orientation | horizontal | verticalDefault: horizontal | Pane layout direction. |
| min / max | numberDefault: 20 / 80 | First-pane percentage bounds. |
| disabled | booleanDefault: false | Disable separator interaction. |