{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"split-pane","type":"registry:component","title":"Split pane","description":"A desktop-grade resizable dual panel. Zero-lag drag, rubber-band spring limits, and double-click preset snapping.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/split-pane.tsx","type":"registry:component","content":"// Hairline UI · split-pane · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\n/* ─────────────────────────────────────────────────────────\n * SPLIT PANE: desktop-grade resizable dual surface\n *\n *   divider    1px hairline border with a centered grab pill;\n *              hover expands the line to an active primary accent\n *   drag       zero-lag direct DOM updates; dragging beyond\n *              min/max limits introduces logarithmic rubber-band\n *              resistance\n *   spring     releasing past boundary limits triggers an\n *              analytical WAAPI damped harmonic spring back\n *              to the boundary threshold\n *   snapping   double-clicking the divider smoothly cycles\n *              between 50%, 25%, and 75% preset layouts\n *   collapse   built-in controls fold either pane to 0 width,\n *              docking the hairline divider flush to the edge\n *\n * Fully accessible separator semantics with keyboard step.\n * ───────────────────────────────────────────────────────── */\n\nimport * as React from 'react';\nimport { ChevronLeft, ChevronRight, GripVertical } from 'lucide-react';\nimport { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { useSplitPane } from './use-split-pane';\n\nexport interface SplitPaneProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  first: React.ReactNode;\n  second: React.ReactNode;\n  split?: number;\n  defaultSplit?: number;\n  minSize?: number;\n  maxSize?: number;\n  orientation?: 'horizontal' | 'vertical';\n  collapsible?: boolean;\n  disabled?: boolean;\n  onSplitChange?: (split: number) => void;\n  onSplitCommit?: (split: number) => void;\n}\n\nexport function SplitPane({\n  first,\n  second,\n  split,\n  defaultSplit = 50,\n  minSize = 15,\n  maxSize = 85,\n  orientation = 'horizontal',\n  collapsible = true,\n  disabled = false,\n  onSplitChange,\n  onSplitCommit,\n  className = '',\n  ...props\n}: SplitPaneProps) {\n  const reduced = useInteractionReducedMotion();\n  const isHorizontal = orientation !== 'vertical';\n\n  const pane = useSplitPane({\n    orientation,\n    split,\n    defaultSplit,\n    minSize,\n    maxSize,\n    disabled,\n    reduced,\n    onChange: onSplitChange,\n    onCommit: onSplitCommit,\n  });\n\n  return (\n    <div\n      {...props}\n      ref={pane.containerRef}\n      className={`relative flex select-none overflow-hidden rounded-[14px] border border-border bg-background shadow-[0_0_0_1px_var(--border)] ${\n        isHorizontal ? 'flex-row' : 'flex-col'\n      } ${className}`}\n    >\n      {/* First Pane */}\n      <div\n        ref={pane.firstPaneRef}\n        className={`relative min-h-0 min-w-0 overflow-hidden ${\n          pane.collapsed === 'first' ? 'pointer-events-none' : ''\n        }`}\n        style={{ flexBasis: `${defaultSplit}%` }}\n      >\n        <div className=\"size-full overflow-auto\">{first}</div>\n      </div>\n\n      {/* 1px Hairline Divider & Grab Handle */}\n      <div className={`relative z-10 shrink-0 ${isHorizontal ? 'w-px cursor-col-resize' : 'h-px cursor-row-resize'}`}>\n        {/* Full Hairline Line */}\n        <div className={`absolute inset-0 bg-border transition-colors ${pane.dragging ? 'bg-primary' : 'hover:bg-primary/70'}`} />\n\n        {/* Focusable and Draggable Touch Target Button */}\n        <button\n          ref={pane.dividerRef}\n          {...pane.dividerProps}\n          type=\"button\"\n          role=\"separator\"\n          disabled={disabled}\n          aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}\n          aria-valuenow={Math.round(split ?? defaultSplit)}\n          aria-valuemin={minSize}\n          aria-valuemax={maxSize}\n          aria-label=\"Resize split panes\"\n          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 ${\n            isHorizontal ? 'left-1/2 top-1/2 h-8 w-4' : 'left-1/2 top-1/2 h-4 w-8'\n          } ${INTERACTION_FOCUS}`}\n        >\n          <GripVertical className=\"size-3.5 opacity-60 transition-opacity group-hover:opacity-100\" />\n        </button>\n\n        {/* Optional Collapsible Controls */}\n        {collapsible && !pane.dragging && (\n          <div\n            className={`absolute z-20 flex items-center gap-0.5 ${\n              isHorizontal ? 'left-1/2 top-3 -translate-x-1/2 flex-col' : 'left-3 top-1/2 -translate-y-1/2 flex-row'\n            }`}\n          >\n            {pane.collapsed === 'first' ? (\n              <button\n                type=\"button\"\n                onClick={pane.expand}\n                title=\"Restore pane\"\n                className=\"cursor-pointer rounded-full border border-border bg-surface p-0.5 text-muted-foreground shadow-sm transition-colors hover:text-foreground\"\n              >\n                <ChevronRight className=\"size-3\" />\n              </button>\n            ) : pane.collapsed === 'second' ? (\n              <button\n                type=\"button\"\n                onClick={pane.expand}\n                title=\"Restore pane\"\n                className=\"cursor-pointer rounded-full border border-border bg-surface p-0.5 text-muted-foreground shadow-sm transition-colors hover:text-foreground\"\n              >\n                <ChevronLeft className=\"size-3\" />\n              </button>\n            ) : (\n              <div className=\"flex gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:opacity-100\">\n                <button\n                  type=\"button\"\n                  onClick={() => pane.collapse('first')}\n                  title=\"Collapse left\"\n                  className=\"cursor-pointer rounded border border-border bg-surface p-0.5 text-muted-foreground hover:text-foreground\"\n                >\n                  <ChevronLeft className=\"size-2.5\" />\n                </button>\n                <button\n                  type=\"button\"\n                  onClick={() => pane.collapse('second')}\n                  title=\"Collapse right\"\n                  className=\"cursor-pointer rounded border border-border bg-surface p-0.5 text-muted-foreground hover:text-foreground\"\n                >\n                  <ChevronRight className=\"size-2.5\" />\n                </button>\n              </div>\n            )}\n          </div>\n        )}\n      </div>\n\n      {/* Second Pane */}\n      <div\n        className={`relative min-h-0 min-w-0 flex-1 overflow-hidden ${\n          pane.collapsed === 'second' ? 'pointer-events-none' : ''\n        }`}\n      >\n        <div className=\"size-full overflow-auto\">{second}</div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"registry/use-split-pane.ts","type":"registry:component","content":"// Hairline UI · split-pane · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { EASE_OUT, springFrames } from './ui-interaction-motion';\n\ntype Orientation = 'horizontal' | 'vertical';\n\ntype SplitPaneOptions = {\n  orientation?: Orientation;\n  split?: number; // 0 to 100\n  defaultSplit?: number;\n  minSize?: number; // default 15\n  maxSize?: number; // default 85\n  disabled?: boolean;\n  reduced?: boolean;\n  onChange?: (split: number) => void;\n  onCommit?: (split: number) => void;\n};\n\nexport function useSplitPane(options: SplitPaneOptions) {\n  const containerRef = React.useRef<HTMLDivElement>(null);\n  const firstPaneRef = React.useRef<HTMLDivElement>(null);\n  const dividerRef = React.useRef<HTMLButtonElement>(null);\n\n  const optionsRef = React.useRef(options);\n  optionsRef.current = options;\n\n  const [dragging, setDragging] = React.useState(false);\n  const [collapsed, setCollapsed] = React.useState<'first' | 'second' | null>(null);\n\n  const gesture = React.useRef<{\n    id: number;\n    startSplit: number;\n    currentSplit: number;\n    containerSize: number;\n    containerStart: number;\n  } | null>(null);\n\n  const min = options.minSize ?? 15;\n  const max = options.maxSize ?? 85;\n\n  const paint = React.useCallback(\n    (splitPercent: number, animate: boolean) => {\n      const o = optionsRef.current;\n      const pane = firstPaneRef.current;\n      if (pane) {\n        pane.style.transition = animate && !o.reduced ? `flex-basis 380ms ${EASE_OUT}` : 'none';\n        pane.style.flexBasis = `${splitPercent}%`;\n      }\n      dividerRef.current?.setAttribute('aria-valuenow', String(Math.round(splitPercent)));\n    },\n    []\n  );\n\n  const commit = React.useCallback(\n    (splitPercent: number, animate = true) => {\n      const clean = Math.max(min, Math.min(max, splitPercent));\n      paint(clean, animate);\n      setCollapsed(null);\n      optionsRef.current.onChange?.(clean);\n      optionsRef.current.onCommit?.(clean);\n    },\n    [min, max, paint]\n  );\n\n  const collapse = React.useCallback(\n    (side: 'first' | 'second') => {\n      const pane = firstPaneRef.current;\n      if (!pane) return;\n      setCollapsed(side);\n      const targetPercent = side === 'first' ? 0 : 100;\n      paint(targetPercent, true);\n      optionsRef.current.onChange?.(targetPercent);\n      optionsRef.current.onCommit?.(targetPercent);\n    },\n    [paint]\n  );\n\n  const expand = React.useCallback(() => {\n    setCollapsed(null);\n    commit(optionsRef.current.defaultSplit ?? 50, true);\n  }, [commit]);\n\n  const snapCycle = React.useCallback(() => {\n    const o = optionsRef.current;\n    const current = o.split ?? o.defaultSplit ?? 50;\n    let next = 50;\n    if (Math.abs(current - 50) < 3) next = 25;\n    else if (Math.abs(current - 25) < 3) next = 75;\n    else next = 50;\n    commit(next, true);\n  }, [commit]);\n\n  const finish = React.useCallback(\n    (cancelled: boolean) => {\n      const g = gesture.current;\n      if (!g) return;\n      gesture.current = null;\n      setDragging(false);\n\n      if (cancelled) {\n        paint(g.startSplit, true);\n        optionsRef.current.onChange?.(g.startSplit);\n      } else {\n        // If release was in rubber-band territory beyond min or max, spring back smoothly\n        const o = optionsRef.current;\n        const current = g.currentSplit;\n        if (current < min || current > max) {\n          const target = current < min ? min : max;\n          const pane = firstPaneRef.current;\n          if (pane && !o.reduced) {\n            const frames = springFrames(current, target, 0, 480);\n            const keyframes = frames.map((f) => ({ flexBasis: `${f.value}%` }));\n            const anim = pane.animate(keyframes, { duration: 480, fill: 'forwards' });\n            anim.onfinish = () => {\n              pane.style.flexBasis = `${target}%`;\n              anim.cancel();\n              commit(target, false);\n            };\n          } else {\n            commit(target, false);\n          }\n        } else {\n          commit(g.currentSplit, false);\n        }\n      }\n    },\n    [min, max, commit, paint]\n  );\n\n  React.useLayoutEffect(() => {\n    if (!gesture.current && collapsed === null) {\n      const val = options.split ?? options.defaultSplit ?? 50;\n      paint(val, true);\n    }\n  }, [options.split, options.defaultSplit, options.reduced, collapsed, paint]);\n\n  const startDrag = React.useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return;\n      const container = containerRef.current;\n      if (!container) return;\n\n      const rect = container.getBoundingClientRect();\n      const isHorizontal = optionsRef.current.orientation !== 'vertical';\n      const containerSize = isHorizontal ? rect.width : rect.height;\n      const containerStart = isHorizontal ? rect.left : rect.top;\n\n      event.currentTarget.setPointerCapture(event.pointerId);\n      event.currentTarget.focus({ preventScroll: true });\n\n      const currentSplit = optionsRef.current.split ?? optionsRef.current.defaultSplit ?? 50;\n\n      gesture.current = {\n        id: event.pointerId,\n        startSplit: currentSplit,\n        currentSplit,\n        containerSize: Math.max(1, containerSize),\n        containerStart,\n      };\n\n      setDragging(true);\n      setCollapsed(null);\n      paint(currentSplit, false);\n    },\n    [paint]\n  );\n\n  const onPointerMove = React.useCallback(\n    (event: React.PointerEvent<HTMLButtonElement>) => {\n      const g = gesture.current;\n      const o = optionsRef.current;\n      if (!g || g.id !== event.pointerId) return;\n\n      const isHorizontal = o.orientation !== 'vertical';\n      const clientPos = isHorizontal ? event.clientX : event.clientY;\n      const rawPercent = ((clientPos - g.containerStart) / g.containerSize) * 100;\n\n      // Rubber-band resistance curve beyond min/max limits\n      let dampedPercent = rawPercent;\n      if (rawPercent < min) {\n        const overflow = min - rawPercent;\n        dampedPercent = min - Math.pow(overflow, 0.7);\n      } else if (rawPercent > max) {\n        const overflow = rawPercent - max;\n        dampedPercent = max + Math.pow(overflow, 0.7);\n      }\n\n      g.currentSplit = dampedPercent;\n      paint(dampedPercent, false);\n      o.onChange?.(dampedPercent);\n    },\n    [min, max, paint]\n  );\n\n  const onKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLButtonElement>) => {\n      if (event.key === 'Escape') {\n        event.preventDefault();\n        finish(true);\n        return;\n      }\n\n      const isHorizontal = optionsRef.current.orientation !== 'vertical';\n      const prevKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp';\n      const nextKey = isHorizontal ? 'ArrowRight' : 'ArrowDown';\n\n      if (![prevKey, nextKey, 'Home', 'End', 'Enter'].includes(event.key)) return;\n      event.preventDefault();\n\n      if (event.key === 'Enter') {\n        snapCycle();\n        return;\n      }\n\n      const current = optionsRef.current.split ?? optionsRef.current.defaultSplit ?? 50;\n      if (event.key === 'Home') {\n        commit(min);\n        return;\n      }\n      if (event.key === 'End') {\n        commit(max);\n        return;\n      }\n\n      const mult = event.shiftKey ? 10 : 1;\n      const delta = (event.key === nextKey ? 1 : -1) * mult;\n      commit(current + delta);\n    },\n    [min, max, snapCycle, commit, finish]\n  );\n\n  return {\n    containerRef,\n    firstPaneRef,\n    dividerRef,\n    dragging,\n    collapsed,\n    collapse,\n    expand,\n    snapCycle,\n    dividerProps: {\n      onPointerDown: startDrag,\n      onPointerMove,\n      onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(false);\n      },\n      onPointerCancel: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onLostPointerCapture: (e: React.PointerEvent<HTMLButtonElement>) => {\n        if (e.pointerId === gesture.current?.id) finish(true);\n      },\n      onDoubleClick: snapCycle,\n      onBlur: () => finish(true),\n      onKeyDown,\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · split-pane · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport 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';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  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}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/split-pane. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}