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

$ npx shadcn@latest add https://hairlineui.com/r/split-pane.json

Overview

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

usage.tsx
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.

example.tsx
"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

PropTypeDescription
first / secondReact.ReactNodeContent rendered inside the left/top and right/bottom panes.
split / defaultSplitnumberdefault 50Controlled or initial percentage allocated to the first pane.
minSize / maxSizenumberdefault 15 / 85Boundary thresholds in percentage before rubber-band resistance begins.
orientation"horizontal" | "vertical"default "horizontal"Primary axis of separation.
collapsiblebooleandefault trueEnables pane collapse buttons on the divider.
disabledbooleandefault falseLocks the divider in place and prevents resizing.
onSplitChange(split: number) => voidFired during pointer movement with raw split percentages.
onSplitCommit(split: number) => voidFired 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

components/split-pane.tsx
'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>  );}
components/use-split-pane.ts
'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,    },  };}
components/ui-interaction-motion.ts
'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