Split View
Two-pane master/detail layout with a draggable divider, keyboard resize, min/max bounds and horizontal or vertical orientation. RTL-aware.
Example
Installation
npx shadcn@latest add https://hirael.com/r/split-view.jsonAPI
<SplitView />
+ native element props| Prop | Type | Default |
|---|---|---|
orientation | SplitOrientation | "horizontal" |
defaultSizeSize of the first panel, as a percentage. | number | 50 |
minSizeMinimum size of the first panel, as a percentage. | number | 15 |
maxSizeMaximum size of the first panel, as a percentage. | number | 85 |
<SplitViewPanel />
+ native element propsNo props of its own — forwards everything to the underlying element.
<SplitViewResizer />
+ native element propsNo props of its own — forwards everything to the underlying element.
Component source
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type SplitOrientation = "horizontal" | "vertical";
type SplitViewContextValue = {
orientation: SplitOrientation;
size: number;
dragging: boolean;
onResizerPointerDown: (event: React.PointerEvent) => void;
onResizerKeyDown: (event: React.KeyboardEvent) => void;
};
const SplitViewContext = React.createContext<SplitViewContextValue | null>(
null,
);
function useSplitView() {
const ctx = React.useContext(SplitViewContext);
if (!ctx) {
throw new Error("SplitView parts must be used within <SplitView>");
}
return ctx;
}
function isRtl(el: HTMLElement | null) {
return el ? getComputedStyle(el).direction === "rtl" : false;
}
type SplitViewProps = React.ComponentProps<"div"> & {
orientation?: SplitOrientation;
/** Size of the first panel, as a percentage. */
defaultSize?: number;
/** Minimum size of the first panel, as a percentage. */
minSize?: number;
/** Maximum size of the first panel, as a percentage. */
maxSize?: number;
};
function SplitView({
orientation = "horizontal",
defaultSize = 50,
minSize = 15,
maxSize = 85,
className,
style,
children,
...props
}: SplitViewProps) {
const ref = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState(defaultSize);
const [dragging, setDragging] = React.useState(false);
const clamp = React.useCallback(
(value: number) => Math.min(maxSize, Math.max(minSize, value)),
[minSize, maxSize],
);
const resizeToPointer = React.useCallback(
(clientX: number, clientY: number) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let pct =
orientation === "horizontal"
? ((clientX - rect.left) / rect.width) * 100
: ((clientY - rect.top) / rect.height) * 100;
if (orientation === "horizontal" && isRtl(el)) pct = 100 - pct;
setSize(clamp(pct));
},
[orientation, clamp],
);
const onResizerPointerDown = React.useCallback(
(event: React.PointerEvent) => {
event.preventDefault();
const handle = event.currentTarget as HTMLElement;
handle.setPointerCapture(event.pointerId);
setDragging(true);
const onMove = (ev: PointerEvent) =>
resizeToPointer(ev.clientX, ev.clientY);
const onUp = (ev: PointerEvent) => {
if (handle.hasPointerCapture(ev.pointerId)) {
handle.releasePointerCapture(ev.pointerId);
}
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
setDragging(false);
};
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
},
[resizeToPointer],
);
const onResizerKeyDown = React.useCallback(
(event: React.KeyboardEvent) => {
const step = event.shiftKey ? 10 : 2;
let delta = 0;
if (orientation === "horizontal") {
if (event.key === "ArrowLeft") delta = -step;
else if (event.key === "ArrowRight") delta = step;
if (isRtl(ref.current)) delta = -delta;
} else {
if (event.key === "ArrowUp") delta = -step;
else if (event.key === "ArrowDown") delta = step;
}
if (delta !== 0) {
event.preventDefault();
setSize((value) => clamp(value + delta));
}
},
[orientation, clamp],
);
const value = React.useMemo<SplitViewContextValue>(
() => ({
orientation,
size,
dragging,
onResizerPointerDown,
onResizerKeyDown,
}),
[orientation, size, dragging, onResizerPointerDown, onResizerKeyDown],
);
return (
<SplitViewContext.Provider value={value}>
<div
ref={ref}
data-slot="split-view"
data-orientation={orientation}
className={cn(
"flex min-h-0 min-w-0 overflow-hidden rounded-lg border border-border bg-card",
orientation === "vertical" ? "flex-col" : "flex-row",
dragging && "select-none",
"[&>[data-slot=split-view-panel]:first-child]:shrink-0 [&>[data-slot=split-view-panel]:first-child]:grow-0 [&>[data-slot=split-view-panel]:first-child]:basis-[var(--split-pos)]",
"[&>[data-slot=split-view-panel]:last-child]:flex-1",
className,
)}
style={{ ["--split-pos" as string]: `${size}%`, ...style }}
{...props}
>
{children}
</div>
</SplitViewContext.Provider>
);
}
type SplitViewPanelProps = React.ComponentProps<"div">;
function SplitViewPanel({ className, ...props }: SplitViewPanelProps) {
return (
<div
data-slot="split-view-panel"
className={cn("min-h-0 min-w-0 overflow-auto", className)}
{...props}
/>
);
}
type SplitViewResizerProps = React.ComponentProps<"div">;
function SplitViewResizer({ className, ...props }: SplitViewResizerProps) {
const {
orientation,
size,
dragging,
onResizerPointerDown,
onResizerKeyDown,
} = useSplitView();
return (
<div
role="separator"
tabIndex={0}
aria-orientation={
orientation === "horizontal" ? "vertical" : "horizontal"
}
aria-valuenow={Math.round(size)}
aria-valuemin={0}
aria-valuemax={100}
data-slot="split-view-resizer"
data-dragging={dragging ? "" : undefined}
onPointerDown={onResizerPointerDown}
onKeyDown={onResizerKeyDown}
className={cn(
"relative shrink-0 bg-border transition-colors hover:bg-ring focus-visible:outline-none focus-visible:bg-ring data-[dragging]:bg-ring",
orientation === "horizontal"
? "w-px cursor-col-resize before:absolute before:inset-y-0 before:-inset-x-1"
: "h-px cursor-row-resize before:absolute before:inset-x-0 before:-inset-y-1",
className,
)}
{...props}
/>
);
}
export { SplitView, SplitViewPanel, SplitViewResizer };