Split pane
Resizable two-pane layout with a draggable rule.
npx shadcn@latest add https://ui.thjsecurity.tech/r/split-pane.jsonPulls the component into components/ui/split-pane.tsx and its token dependencies. The app owns the copy from then on.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/* Two panes side by side with a draggable gutter between them, the way an
editor splits a file tree from the file. The gutter shows a handle on
hover, drags with the pointer, resets on double click, and the width is
remembered per browser under `storageKey`. Below the `lg` breakpoint the
panes stack and the gutter is gone. Each pane keeps its own height. */
// The widths live in a tiny store over localStorage, read through
// useSyncExternalStore: the server renders the default, the browser then
// renders the remembered width, and React handles the switch itself (an
// attribute set during hydration would otherwise keep the server's value).
const listeners = new Set<() => void>()
const cache = new Map<string, number>()
function read(key: string, fallback: number, min: number, max: number): number {
const hit = cache.get(key)
if (hit !== undefined) return hit
let v = fallback
try {
const saved = Number(window.localStorage.getItem(key))
if (saved >= min && saved <= max) v = saved
} catch {
// storage can be unavailable; the default width is fine
}
cache.set(key, v)
return v
}
function write(key: string, v: number) {
cache.set(key, v)
try {
window.localStorage.setItem(key, String(v))
} catch {
// ignore
}
listeners.forEach((l) => l())
}
function subscribe(l: () => void) {
listeners.add(l)
return () => listeners.delete(l)
}
export function SplitPane({
left,
right,
defaultWidth = 300,
min = 220,
max = 640,
storageKey,
className,
}: {
left: React.ReactNode
right: React.ReactNode
defaultWidth?: number
min?: number
max?: number
storageKey: string
className?: string
}) {
const width = React.useSyncExternalStore(
subscribe,
() => read(storageKey, defaultWidth, min, max),
() => defaultWidth,
)
const [dragging, setDragging] = React.useState(false)
const ref = React.useRef<HTMLDivElement>(null)
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault()
const box = ref.current?.getBoundingClientRect()
if (!box) return
setDragging(true)
const clamp = (x: number) => Math.min(max, Math.max(min, x - box.left))
const move = (ev: PointerEvent) => write(storageKey, clamp(ev.clientX))
const up = () => {
window.removeEventListener("pointermove", move)
window.removeEventListener("pointerup", up)
setDragging(false)
}
window.addEventListener("pointermove", move)
window.addEventListener("pointerup", up)
}
return (
<div ref={ref} className={cn("grid gap-5 lg:gap-0", dragging && "select-none", className)} data-split>
<style>{`@media (min-width: 1024px) { [data-split] { grid-template-columns: ${width}px 20px minmax(0, 1fr); } }`}</style>
<div className="min-w-0 lg:sticky lg:top-20 lg:self-start">{left}</div>
<div
role="separator"
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={min}
aria-valuemax={max}
title="Drag to resize, double-click to reset"
onPointerDown={onPointerDown}
onDoubleClick={() => write(storageKey, defaultWidth)}
className="group hidden cursor-col-resize items-stretch justify-center lg:flex"
>
<span
className={cn(
"my-6 w-[3px] rounded-full bg-transparent transition-colors group-hover:bg-rule-hot",
dragging && "!bg-signal",
)}
/>
</div>
<div className="min-w-0 self-start">{right}</div>
</div>
)
}