Layout

Split pane

Resizable two-pane layout with a draggable rule.

Preview
Live demo coming - the component is in the registry below.
Install
npx shadcn@latest add https://ui.thjsecurity.tech/r/split-pane.json

Pulls the component into components/ui/split-pane.tsx and its token dependencies. The app owns the copy from then on.

Source
"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>
  )
}