Split pane
A desktop-grade resizable dual panel. Zero-lag drag, rubber-band spring limits, and double-click preset snapping.
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/split-pane.jsonOverview
Divide views cleanly with a 1px hairline boundary. Dragging adjusts panel ratios with immediate DOM updates, pulling past boundaries encounters logarithmic resistance before springing back through a damped harmonic oscillator (WAAPI), and double-clicking snaps cleanly between 50%, 25%, and 75% presets.
States
- resize
- Divider movement updates pane flex-basis directly without React state re-renders.
- rubber-band
- Dragging beyond min/max bounds introduces logarithmic resistance; release triggers a WAAPI spring back.
- snap
- Double-clicking the divider smoothly animates between 50/50, 25/75, and 75/25 presets over 380ms.
- collapse
- Collapse arrows fold either pane to 0 width, docking the hairline divider flush against the frame.
Usage
import { SplitPane } from "@/components/split-pane";<SplitPane defaultSplit={50} first={<div className="p-4">Left Editor</div>} second={<div className="p-4">Right Preview</div>} onSplitCommit={setSplit}/>Side-by-side markdown and live preview
Build dual-surface workspaces with independent scroll containers and zero layout jitter. Ideal for code playgrounds, markdown editors, and comparison screens.
"use client";import { useState } from "react";import { SplitPane } from "@/components/split-pane";export function DualWorkspace() { const [split, setSplit] = useState(50); return ( <div className="h-[480px] w-full rounded-2xl border border-border"> <SplitPane split={split} first={<div className="h-full bg-surface p-4 font-mono text-xs"># Document Source</div>} second={<div className="h-full bg-background p-4 text-sm">Rendered Output</div>} onSplitCommit={setSplit} /> </div> );}Props
| Prop | Type | Description |
|---|---|---|
| first / second | React.ReactNode | Content rendered inside the left/top and right/bottom panes. |
| split / defaultSplit | numberdefault 50 | Controlled or initial percentage allocated to the first pane. |
| minSize / maxSize | numberdefault 15 / 85 | Boundary thresholds in percentage before rubber-band resistance begins. |
| orientation | "horizontal" | "vertical"default "horizontal" | Primary axis of separation. |
| collapsible | booleandefault true | Enables pane collapse buttons on the divider. |
| disabled | booleandefault false | Locks the divider in place and prevents resizing. |
| onSplitChange | (split: number) => void | Fired during pointer movement with raw split percentages. |
| onSplitCommit | (split: number) => void | Fired upon pointer release or keyboard snap with normalized value. |
Accessibility and motion
- The divider button acts as a semantic role="separator" with aria-valuenow.
- Arrow keys step by 1% (10% with Shift); Enter snaps to 50/50; Home and End jump to minimum and maximum limits.
- Past boundary limits, WAAPI springFrames computes 33 keyframe values for fluid physical return.
- Reduced motion removes transitions and settles snapped positions immediately.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
Source
'use client';/* ───────────────────────────────────────────────────────── * SPLIT PANE: desktop-grade resizable dual surface * * divider 1px hairline border with a centered grab pill; * hover expands the line to an active primary accent * drag zero-lag direct DOM updates; dragging beyond * min/max limits introduces logarithmic rubber-band * resistance * spring releasing past boundary limits triggers an * analytical WAAPI damped harmonic spring back * to the boundary threshold * snapping double-clicking the divider smoothly cycles * between 50%, 25%, and 75% preset layouts * collapse built-in controls fold either pane to 0 width, * docking the hairline divider flush to the edge * * Fully accessible separator semantics with keyboard step. * ───────────────────────────────────────────────────────── */import * as React from 'react';import { ChevronLeft, ChevronRight, GripVertical } from 'lucide-react';import { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';import { useSplitPane } from './use-split-pane';export interface SplitPaneProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> { first: React.ReactNode; second: React.ReactNode; split?: number; defaultSplit?: number; minSize?: number; maxSize?: number; orientation?: 'horizontal' | 'vertical'; collapsible?: boolean; disabled?: boolean; onSplitChange?: (split: number) => void; onSplitCommit?: (split: number) => void;}export function SplitPane({ first, second, split, defaultSplit = 50, minSize = 15, maxSize = 85, orientation = 'horizontal', collapsible = true, disabled = false, onSplitChange, onSplitCommit, className = '', ...props}: SplitPaneProps) { const reduced = useInteractionReducedMotion(); const isHorizontal = orientation !== 'vertical'; const pane = useSplitPane({ orientation, split, defaultSplit, minSize, maxSize, disabled, reduced, onChange: onSplitChange, onCommit: onSplitCommit, }); return ( <div {...props} ref={pane.containerRef} className={`relative flex select-none overflow-hidden rounded-[14px] border border-border bg-background shadow-[0_0_0_1px_var(--border)] ${ isHorizontal ? 'flex-row' : 'flex-col' } ${className}`} > {/* First Pane */} <div ref={pane.firstPaneRef} className={`relative min-h-0 min-w-0 overflow-hidden ${ pane.collapsed === 'first' ? 'pointer-events-none' : '' }`} style={{ flexBasis: `${defaultSplit}%` }} > <div className="size-full overflow-auto">{first}</div> </div> {/* 1px Hairline Divider & Grab Handle */} <div className={`relative z-10 shrink-0 ${isHorizontal ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize'}`}> {/* Full Hairline Line */} <div className={`absolute inset-0 bg-border transition-colors ${pane.dragging ? 'bg-primary' : 'hover:bg-primary/70'}`} /> {/* Focusable and Draggable Touch Target Button */} <button ref={pane.dividerRef} {...pane.dividerProps} type="button" role="separator" disabled={disabled} aria-orientation={isHorizontal ? 'vertical' : 'horizontal'} aria-valuenow={Math.round(split ?? defaultSplit)} aria-valuemin={minSize} aria-valuemax={maxSize} aria-label="Resize split panes" className={`group absolute -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-background p-1 text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground active:scale-95 disabled:cursor-default disabled:opacity-40 ${ isHorizontal ? 'left-1/2 top-1/2 h-8 w-4' : 'left-1/2 top-1/2 h-4 w-8' } ${INTERACTION_FOCUS}`} > <GripVertical className="size-3.5 opacity-60 transition-opacity group-hover:opacity-100" /> </button> {/* Optional Collapsible Controls */} {collapsible && !pane.dragging && ( <div className={`absolute z-20 flex items-center gap-0.5 ${ isHorizontal ? 'left-1/2 top-3 -translate-x-1/2 flex-col' : 'left-3 top-1/2 -translate-y-1/2 flex-row' }`} > {pane.collapsed === 'first' ? ( <button type="button" onClick={pane.expand} title="Restore pane" className="cursor-pointer rounded-full border border-border bg-surface p-0.5 text-muted-foreground shadow-sm transition-colors hover:text-foreground" > <ChevronRight className="size-3" /> </button> ) : pane.collapsed === 'second' ? ( <button type="button" onClick={pane.expand} title="Restore pane" className="cursor-pointer rounded-full border border-border bg-surface p-0.5 text-muted-foreground shadow-sm transition-colors hover:text-foreground" > <ChevronLeft className="size-3" /> </button> ) : ( <div className="flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100"> <button type="button" onClick={() => pane.collapse('first')} title="Collapse left" className="cursor-pointer rounded border border-border bg-surface p-0.5 text-muted-foreground hover:text-foreground" > <ChevronLeft className="size-2.5" /> </button> <button type="button" onClick={() => pane.collapse('second')} title="Collapse right" className="cursor-pointer rounded border border-border bg-surface p-0.5 text-muted-foreground hover:text-foreground" > <ChevronRight className="size-2.5" /> </button> </div> )} </div> )} </div> {/* Second Pane */} <div className={`relative min-h-0 min-w-0 flex-1 overflow-hidden ${ pane.collapsed === 'second' ? 'pointer-events-none' : '' }`} > <div className="size-full overflow-auto">{second}</div> </div> </div> );}'use client';import * as React from 'react';import { EASE_OUT, springFrames } from './ui-interaction-motion';type Orientation = 'horizontal' | 'vertical';type SplitPaneOptions = { orientation?: Orientation; split?: number; // 0 to 100 defaultSplit?: number; minSize?: number; // default 15 maxSize?: number; // default 85 disabled?: boolean; reduced?: boolean; onChange?: (split: number) => void; onCommit?: (split: number) => void;};export function useSplitPane(options: SplitPaneOptions) { const containerRef = React.useRef<HTMLDivElement>(null); const firstPaneRef = React.useRef<HTMLDivElement>(null); const dividerRef = React.useRef<HTMLButtonElement>(null); const optionsRef = React.useRef(options); optionsRef.current = options; const [dragging, setDragging] = React.useState(false); const [collapsed, setCollapsed] = React.useState<'first' | 'second' | null>(null); const gesture = React.useRef<{ id: number; startSplit: number; currentSplit: number; containerSize: number; containerStart: number; } | null>(null); const min = options.minSize ?? 15; const max = options.maxSize ?? 85; const paint = React.useCallback( (splitPercent: number, animate: boolean) => { const o = optionsRef.current; const pane = firstPaneRef.current; if (pane) { pane.style.transition = animate && !o.reduced ? `flex-basis 380ms ${EASE_OUT}` : 'none'; pane.style.flexBasis = `${splitPercent}%`; } dividerRef.current?.setAttribute('aria-valuenow', String(Math.round(splitPercent))); }, [] ); const commit = React.useCallback( (splitPercent: number, animate = true) => { const clean = Math.max(min, Math.min(max, splitPercent)); paint(clean, animate); setCollapsed(null); optionsRef.current.onChange?.(clean); optionsRef.current.onCommit?.(clean); }, [min, max, paint] ); const collapse = React.useCallback( (side: 'first' | 'second') => { const pane = firstPaneRef.current; if (!pane) return; setCollapsed(side); const targetPercent = side === 'first' ? 0 : 100; paint(targetPercent, true); optionsRef.current.onChange?.(targetPercent); optionsRef.current.onCommit?.(targetPercent); }, [paint] ); const expand = React.useCallback(() => { setCollapsed(null); commit(optionsRef.current.defaultSplit ?? 50, true); }, [commit]); const snapCycle = React.useCallback(() => { const o = optionsRef.current; const current = o.split ?? o.defaultSplit ?? 50; let next = 50; if (Math.abs(current - 50) < 3) next = 25; else if (Math.abs(current - 25) < 3) next = 75; else next = 50; commit(next, true); }, [commit]); const finish = React.useCallback( (cancelled: boolean) => { const g = gesture.current; if (!g) return; gesture.current = null; setDragging(false); if (cancelled) { paint(g.startSplit, true); optionsRef.current.onChange?.(g.startSplit); } else { // If release was in rubber-band territory beyond min or max, spring back smoothly const o = optionsRef.current; const current = g.currentSplit; if (current < min || current > max) { const target = current < min ? min : max; const pane = firstPaneRef.current; if (pane && !o.reduced) { const frames = springFrames(current, target, 0, 480); const keyframes = frames.map((f) => ({ flexBasis: `${f.value}%` })); const anim = pane.animate(keyframes, { duration: 480, fill: 'forwards' }); anim.onfinish = () => { pane.style.flexBasis = `${target}%`; anim.cancel(); commit(target, false); }; } else { commit(target, false); } } else { commit(g.currentSplit, false); } } }, [min, max, commit, paint] ); React.useLayoutEffect(() => { if (!gesture.current && collapsed === null) { const val = options.split ?? options.defaultSplit ?? 50; paint(val, true); } }, [options.split, options.defaultSplit, options.reduced, collapsed, paint]); const startDrag = React.useCallback( (event: React.PointerEvent<HTMLButtonElement>) => { if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return; const container = containerRef.current; if (!container) return; const rect = container.getBoundingClientRect(); const isHorizontal = optionsRef.current.orientation !== 'vertical'; const containerSize = isHorizontal ? rect.width : rect.height; const containerStart = isHorizontal ? rect.left : rect.top; event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.focus({ preventScroll: true }); const currentSplit = optionsRef.current.split ?? optionsRef.current.defaultSplit ?? 50; gesture.current = { id: event.pointerId, startSplit: currentSplit, currentSplit, containerSize: Math.max(1, containerSize), containerStart, }; setDragging(true); setCollapsed(null); paint(currentSplit, false); }, [paint] ); const onPointerMove = React.useCallback( (event: React.PointerEvent<HTMLButtonElement>) => { const g = gesture.current; const o = optionsRef.current; if (!g || g.id !== event.pointerId) return; const isHorizontal = o.orientation !== 'vertical'; const clientPos = isHorizontal ? event.clientX : event.clientY; const rawPercent = ((clientPos - g.containerStart) / g.containerSize) * 100; // Rubber-band resistance curve beyond min/max limits let dampedPercent = rawPercent; if (rawPercent < min) { const overflow = min - rawPercent; dampedPercent = min - Math.pow(overflow, 0.7); } else if (rawPercent > max) { const overflow = rawPercent - max; dampedPercent = max + Math.pow(overflow, 0.7); } g.currentSplit = dampedPercent; paint(dampedPercent, false); o.onChange?.(dampedPercent); }, [min, max, paint] ); const onKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLButtonElement>) => { if (event.key === 'Escape') { event.preventDefault(); finish(true); return; } const isHorizontal = optionsRef.current.orientation !== 'vertical'; const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp'; const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown'; if (![prevKey, nextKey, 'Home', 'End', 'Enter'].includes(event.key)) return; event.preventDefault(); if (event.key === 'Enter') { snapCycle(); return; } const current = optionsRef.current.split ?? optionsRef.current.defaultSplit ?? 50; if (event.key === 'Home') { commit(min); return; } if (event.key === 'End') { commit(max); return; } const mult = event.shiftKey ? 10 : 1; const delta = (event.key === nextKey ? 1 : -1) * mult; commit(current + delta); }, [min, max, snapCycle, commit, finish] ); return { containerRef, firstPaneRef, dividerRef, dragging, collapsed, collapse, expand, snapCycle, dividerProps: { onPointerDown: startDrag, onPointerMove, onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(false); }, onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(true); }, onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => { if (e.pointerId === gesture.current?.id) finish(true); }, onDoubleClick: snapCycle, onBlur: () => finish(true), onKeyDown, }, };}'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => { const media = window.matchMedia(query); media.addEventListener('change', notify); return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() { const ref = React.useRef<T>(null); const [visible, setVisible] = React.useState(false); const [hidden, setHidden] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); observer.observe(el); const visibility = () => setHidden(document.hidden); visibility(); document.addEventListener('visibilitychange', visibility); return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); }; }, []); return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties { return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) { samples.push({ x, time }); while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) { if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0; const first = samples[0], last = samples[samples.length - 1]; return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) { const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping); const displacement = from - to; const coefficient = (velocity * 1000 + damping * omega * displacement) / damped; return Array.from({ length: 33 }, (_, i) => { const t = (i / 32) * duration / 1000; const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t)); return { value: i === 32 ? to : value, offset: i / 32 }; });}Registry item: /r/split-pane.json