{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "split-pane-weighted",
  "title": "Split Pane Weighted",
  "description": "Split-pane divider that carries real weight: drag it and the boundary trails the pointer like a counterweighted window sash, release with speed and it coasts into the nearest detent on a critically-damped spring; released slow and far from a stop, it just stays put.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/split-pane-weighted/component.tsx",
      "content": "\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SashWeight — a split-pane divider that behaves like a counterweighted\n// window sash rather than a bare drag handle. Two phases of one spring\n// simulation share a single rAF loop:\n//\n//   1. DRAGGING: while the pointer is down, the divider does not snap to the\n//      pointer 1:1 — the live position chases the pointer through an\n//      overdamped spring (k=90 s^-2, zeta=1.15). That lag IS the weight: a\n//      fast flick visibly trails before catching up, a slow drag tracks it\n//      almost exactly. The spring's own velocity at any instant is therefore\n//      already the divider's real velocity — no separate pointer-velocity\n//      sampling is needed.\n//   2. SETTLING: on release, that carried velocity feeds directly into a\n//      critically-damped spring (k=170 s^-2, zeta=1, no overshoot) aimed at\n//      the nearest detent — but only if the release was fast (>=55%/s) or\n//      already close to a detent (<=6%). Released slow and far from a stop,\n//      the sash simply stays where it was let go; not every release is\n//      supposed to land on a rail.\n//\n// Only the two panes are ever written to imperatively (flex-basis on the\n// start pane; the end pane fills the remainder via flex:1, so it \"resizes\"\n// without a second write) plus the divider's own ARIA value attributes —\n// nothing else in the tree is touched by the animation loop, and no React\n// state re-render drives a single frame of it.\n//\n// A11y: the divider is a real, focusable role=\"separator\" (not a plain\n// draggable div) with aria-orientation and aria-valuenow/min/max as a\n// percentage, so a screen reader reports it as a moveable boundary, not a\n// static rule. Arrow keys move 1% (instant, no spring — a discrete nudge\n// doesn't need inertia); Shift+Arrow jumps to the next detent in that\n// direction via the same settle spring the pointer path uses; Enter toggles\n// between 50/50 and wherever the sash last sat off-center. prefers-reduced-\n// motion strips both spring phases: dragging tracks the pointer exactly,\n// and a release only ever snaps instantly (no coast) when it lands inside\n// the same detent-proximity zone.\n// ---------------------------------------------------------------------------\n\nconst HIT = 8; // px width/height of the divider's pointer hit area\nconst DRAG_K = 90; // s^-2 — spring the live position chases the pointer with\nconst DRAG_ZETA = 1.15; // overdamped: trails a fast pointer, never oscillates\nconst SETTLE_K = 170; // s^-2 — release spring toward the nearest detent\nconst SETTLE_ZETA = 1; // critically damped: closes in without overshoot\nconst SNAP_ZONE = 6; // percent — release within this of a detent always settles\nconst VEL_THRESHOLD = 55; // percent/s — release faster than this always coasts+settles\nconst EPS_POS = 0.05; // percent\nconst EPS_VEL = 0.4; // percent/s\nconst CENTER_EPS = 0.6; // percent — how close to 50 counts as \"already centered\"\n\ntype Orientation = \"vertical\" | \"horizontal\";\ntype Phase = \"idle\" | \"dragging\" | \"settling\";\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nexport interface SashWeightProps {\n  /** \"vertical\" = a vertical divider splitting left/right panes (default). \"horizontal\" = a horizontal divider splitting top/bottom panes. */\n  orientation?: Orientation;\n  /** the leading (left / top) pane's content */\n  start: React.ReactNode;\n  /** the trailing (right / bottom) pane's content */\n  end: React.ReactNode;\n  /** controlled: the start pane's share of the container, as a percentage */\n  value?: number;\n  /** uncontrolled initial share, as a percentage */\n  defaultValue?: number;\n  /** called with the new percentage on every drag/keyboard change */\n  onValueChange?: (percent: number) => void;\n  /** percentage clamp for the divider's travel */\n  min?: number;\n  /** percentage clamp for the divider's travel */\n  max?: number;\n  /** percentages the divider settles toward on a decisive release, Shift+Arrow, or Enter/double-click's 50/50 fallback */\n  detents?: number[];\n  /** accessible name for the divider */\n  ariaLabel?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function SashWeight({\n  orientation = \"vertical\",\n  start,\n  end,\n  value,\n  defaultValue = 50,\n  onValueChange,\n  min = 10,\n  max = 90,\n  detents = [25, 50, 75],\n  ariaLabel = \"Resize panes\",\n  className = \"\",\n}: SashWeightProps) {\n  const isControlled = value !== undefined;\n  const [posState, setPosState] = useState(() => clamp(defaultValue, min, max));\n  const pos = isControlled ? clamp(value, min, max) : posState;\n\n  const [dragging, setDragging] = useState(false);\n  const [hovering, setHovering] = useState(false);\n  const [focused, setFocused] = useState(false);\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const startPaneRef = useRef<HTMLDivElement>(null);\n  const dividerRef = useRef<HTMLDivElement>(null);\n\n  const minRef = useRef(min);\n  minRef.current = min;\n  const maxRef = useRef(max);\n  maxRef.current = max;\n  const detentsRef = useRef(detents);\n  detentsRef.current = detents;\n  const orientationRef = useRef(orientation);\n  orientationRef.current = orientation;\n\n  const posRef = useRef(pos); // last committed value\n  posRef.current = pos;\n  const liveRef = useRef(pos); // current animated / dragged value\n  const velRef = useRef(0);\n  const dragTargetRef = useRef(pos);\n  const settleTargetRef = useRef(pos);\n  const phaseRef = useRef<Phase>(\"idle\");\n  const rafRef = useRef(0);\n  const lastRef = useRef(0);\n  const reducedRef = useRef(false);\n  const lastOffCenterRef = useRef<number | null>(null);\n\n  useLayoutEffect(() => {\n    reducedRef.current = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    return () => {\n      if (rafRef.current) cancelAnimationFrame(rafRef.current);\n    };\n  }, []);\n\n  const writeDOM = (p: number) => {\n    if (startPaneRef.current) startPaneRef.current.style.flexBasis = `${p}%`;\n    const rounded = Math.round(p);\n    const el = dividerRef.current;\n    if (el) {\n      el.setAttribute(\"aria-valuenow\", String(rounded));\n      el.setAttribute(\"aria-valuetext\", `${rounded}% / ${100 - rounded}%`);\n    }\n  };\n\n  // keep the DOM in sync with the committed value whenever nothing is\n  // actively animating it (initial mount, and controlled updates from outside)\n  useLayoutEffect(() => {\n    if (phaseRef.current === \"idle\") {\n      liveRef.current = pos;\n      writeDOM(pos);\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [pos]);\n\n  const commit = (p: number) => {\n    const rounded = Number(p.toFixed(2));\n    posRef.current = rounded;\n    if (!isControlled) setPosState(rounded);\n    onValueChange?.(rounded);\n  };\n\n  const nearestDetent = (p: number) => {\n    const list = detentsRef.current;\n    if (list.length === 0) return p;\n    return list.reduce((best, d) => (Math.abs(d - p) < Math.abs(best - p) ? d : best), list[0]);\n  };\n\n  const cancelRaf = () => {\n    if (rafRef.current) {\n      cancelAnimationFrame(rafRef.current);\n      rafRef.current = 0;\n    }\n  };\n\n  const tick = (now: number) => {\n    const dt = Math.min(0.032, Math.max(0, (now - lastRef.current) / 1000));\n    lastRef.current = now;\n    const lo = minRef.current;\n    const hi = maxRef.current;\n\n    if (phaseRef.current === \"dragging\") {\n      const c = 2 * Math.sqrt(DRAG_K) * DRAG_ZETA;\n      const target = dragTargetRef.current;\n      const accel = -DRAG_K * (liveRef.current - target) - c * velRef.current;\n      velRef.current += accel * dt;\n      liveRef.current = clamp(liveRef.current + velRef.current * dt, lo, hi);\n      writeDOM(liveRef.current);\n      rafRef.current = requestAnimationFrame(tick);\n      return;\n    }\n\n    if (phaseRef.current === \"settling\") {\n      const c = 2 * Math.sqrt(SETTLE_K) * SETTLE_ZETA;\n      const target = settleTargetRef.current;\n      const accel = -SETTLE_K * (liveRef.current - target) - c * velRef.current;\n      velRef.current += accel * dt;\n      liveRef.current = clamp(liveRef.current + velRef.current * dt, lo, hi);\n      writeDOM(liveRef.current);\n      if (Math.abs(liveRef.current - target) < EPS_POS && Math.abs(velRef.current) < EPS_VEL) {\n        liveRef.current = target;\n        velRef.current = 0;\n        writeDOM(target);\n        commit(target);\n        phaseRef.current = \"idle\";\n        rafRef.current = 0;\n        return;\n      }\n      rafRef.current = requestAnimationFrame(tick);\n      return;\n    }\n  };\n\n  const ensureRaf = () => {\n    if (!rafRef.current) {\n      lastRef.current = performance.now();\n      rafRef.current = requestAnimationFrame(tick);\n    }\n  };\n\n  const startSettle = (target: number, initialVel: number) => {\n    settleTargetRef.current = clamp(target, minRef.current, maxRef.current);\n    velRef.current = initialVel;\n    phaseRef.current = \"settling\";\n    ensureRaf();\n  };\n\n  const pointerToPercent = (e: { clientX: number; clientY: number }) => {\n    const rect = containerRef.current!.getBoundingClientRect();\n    const frac =\n      orientationRef.current === \"vertical\"\n        ? (e.clientX - rect.left) / Math.max(1, rect.width)\n        : (e.clientY - rect.top) / Math.max(1, rect.height);\n    return clamp(frac * 100, minRef.current, maxRef.current);\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    dividerRef.current?.setPointerCapture(e.pointerId);\n    cancelRaf();\n    setDragging(true);\n    liveRef.current = posRef.current;\n    velRef.current = 0;\n    dragTargetRef.current = pointerToPercent(e);\n    phaseRef.current = \"dragging\";\n    // under reduced motion, onPointerMove writes the position directly and\n    // no spring ever runs, so no rAF loop is needed for the drag phase\n    if (!reducedRef.current) ensureRaf();\n    e.preventDefault();\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (phaseRef.current !== \"dragging\") return;\n    if (reducedRef.current) {\n      liveRef.current = pointerToPercent(e);\n      writeDOM(liveRef.current);\n      return;\n    }\n    dragTargetRef.current = pointerToPercent(e);\n  };\n\n  const releaseDrag = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (phaseRef.current !== \"dragging\") return;\n    try {\n      dividerRef.current?.releasePointerCapture(e.pointerId);\n    } catch {\n      /* pointer already released */\n    }\n    setDragging(false);\n    const current = liveRef.current;\n    const nearest = nearestDetent(current);\n    const dist = Math.abs(current - nearest);\n\n    if (reducedRef.current) {\n      const final = dist <= SNAP_ZONE ? nearest : current;\n      liveRef.current = final;\n      velRef.current = 0;\n      phaseRef.current = \"idle\";\n      writeDOM(final);\n      commit(final);\n      cancelRaf();\n      return;\n    }\n\n    const speed = Math.abs(velRef.current);\n    if (dist <= SNAP_ZONE || speed >= VEL_THRESHOLD) {\n      startSettle(nearest, velRef.current);\n    } else {\n      phaseRef.current = \"idle\";\n      cancelRaf();\n      commit(current);\n    }\n  };\n\n  const goTo = (target: number) => {\n    const clamped = clamp(target, minRef.current, maxRef.current);\n    cancelRaf();\n    if (reducedRef.current) {\n      liveRef.current = clamped;\n      phaseRef.current = \"idle\";\n      writeDOM(clamped);\n      commit(clamped);\n    } else {\n      liveRef.current = posRef.current;\n      startSettle(clamped, 0);\n    }\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    const vertical = orientationRef.current === \"vertical\";\n    const decKey = vertical ? \"ArrowLeft\" : \"ArrowUp\";\n    const incKey = vertical ? \"ArrowRight\" : \"ArrowDown\";\n\n    if (e.key === decKey || e.key === incKey) {\n      e.preventDefault();\n      const dir = e.key === incKey ? 1 : -1;\n      if (e.shiftKey) {\n        const sorted = [...detentsRef.current].sort((a, b) => a - b);\n        const current = posRef.current;\n        const target =\n          dir > 0\n            ? (sorted.find((d) => d > current + 0.01) ?? maxRef.current)\n            : ([...sorted].reverse().find((d) => d < current - 0.01) ?? minRef.current);\n        goTo(target);\n      } else {\n        cancelRaf();\n        phaseRef.current = \"idle\";\n        const target = clamp(posRef.current + dir, minRef.current, maxRef.current);\n        liveRef.current = target;\n        writeDOM(target);\n        commit(target);\n      }\n      return;\n    }\n\n    if (e.key === \"Enter\") {\n      e.preventDefault();\n      const current = posRef.current;\n      const centered = Math.abs(current - 50) < CENTER_EPS;\n      if (centered && lastOffCenterRef.current != null) {\n        goTo(lastOffCenterRef.current);\n      } else {\n        lastOffCenterRef.current = current;\n        goTo(50);\n      }\n    }\n  };\n\n  const onDoubleClick = () => {\n    if (phaseRef.current === \"dragging\") return;\n    const current = posRef.current;\n    if (Math.abs(current - 50) >= CENTER_EPS) lastOffCenterRef.current = current;\n    goTo(50);\n  };\n\n  const vertical = orientation === \"vertical\";\n  const engaged = dragging || focused;\n  const visibleDetents = detents.filter((d) => d >= min && d <= max);\n\n  const dividerBarColor = dragging\n    ? \"var(--foreground)\"\n    : hovering || focused\n      ? \"color-mix(in srgb, var(--border) 35%, var(--foreground) 65%)\"\n      : \"var(--border)\";\n\n  return (\n    <div\n      ref={containerRef}\n      data-split-pane-weighted-track\n      className={`relative flex h-full w-full ${vertical ? \"flex-row\" : \"flex-col\"} ${className}`}\n    >\n      {/* faint rail marks at each reachable detent — self-explaining before first touch */}\n      {visibleDetents.map((d) => (\n        <span\n          key={d}\n          aria-hidden\n          className=\"pointer-events-none absolute bg-ns-muted\"\n          style={\n            vertical\n              ? { left: `${d}%`, top: 0, width: 1, height: 8, opacity: 0.45, transform: \"translateX(-50%)\" }\n              : { top: `${d}%`, left: 0, height: 1, width: 8, opacity: 0.45, transform: \"translateY(-50%)\" }\n          }\n        />\n      ))}\n\n      <div ref={startPaneRef} className=\"min-h-0 min-w-0 shrink-0 grow-0 overflow-auto\" style={{ flexBasis: `${pos}%` }}>\n        {start}\n      </div>\n\n      <div\n        ref={dividerRef}\n        role=\"separator\"\n        aria-orientation={vertical ? \"vertical\" : \"horizontal\"}\n        aria-label={ariaLabel}\n        aria-valuenow={Math.round(pos)}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuetext={`${Math.round(pos)}% / ${100 - Math.round(pos)}%`}\n        tabIndex={0}\n        className={`relative z-10 shrink-0 grow-0 touch-none select-none outline-none focus-visible:z-20 ${\n          vertical ? \"cursor-col-resize\" : \"cursor-row-resize\"\n        }`}\n        style={{ width: vertical ? HIT : \"100%\", height: vertical ? \"100%\" : HIT }}\n        onPointerDown={onPointerDown}\n        onPointerMove={onPointerMove}\n        onPointerUp={releaseDrag}\n        onPointerCancel={releaseDrag}\n        onPointerEnter={() => setHovering(true)}\n        onPointerLeave={() => setHovering(false)}\n        onFocus={(e) => {\n          if (e.target.matches(\":focus-visible\")) setFocused(true);\n        }}\n        onBlur={() => setFocused(false)}\n        onKeyDown={onKeyDown}\n        onDoubleClick={onDoubleClick}\n      >\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute rounded-full transition-[background-color,width,height] duration-150 ease-out motion-reduce:transition-none\"\n          style={\n            vertical\n              ? {\n                  left: \"50%\",\n                  top: 0,\n                  bottom: 0,\n                  width: dragging ? 2 : 1,\n                  transform: \"translateX(-50%)\",\n                  backgroundColor: dividerBarColor,\n                }\n              : {\n                  top: \"50%\",\n                  left: 0,\n                  right: 0,\n                  height: dragging ? 2 : 1,\n                  transform: \"translateY(-50%)\",\n                  backgroundColor: dividerBarColor,\n                }\n          }\n        />\n        {engaged && (\n          <span\n            aria-hidden\n            className=\"pointer-events-none absolute rounded-full ring-2 ring-ns-accent\"\n            style={\n              vertical\n                ? { left: \"50%\", top: \"50%\", width: HIT, height: 40, transform: \"translate(-50%, -50%)\" }\n                : { top: \"50%\", left: \"50%\", height: HIT, width: 40, transform: \"translate(-50%, -50%)\" }\n            }\n          />\n        )}\n      </div>\n\n      <div className=\"min-h-0 min-w-0 flex-1 overflow-auto\">{end}</div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/split-pane-weighted.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "divider",
      "split-pane",
      "resize",
      "layout",
      "drag",
      "spring",
      "separator",
      "keyboard"
    ],
    "instruction": "Build a resizable split-pane divider (a real ARIA separator, not a bare draggable rule) between a start pane and an end pane, driven by one spring simulation that runs in two phases inside a single rAF loop. PHASE 1, DRAGGING: on pointerdown over an 8px hit area centered on a 1px --border divider, the live position does NOT snap to the pointer 1:1 — instead it chases a `dragTarget` (the pointer's live percentage along the container's main axis) through an overdamped spring, k=90 s^-2, zeta=1.15. That lag is the entire 'weight' effect: flick the pointer fast and the boundary visibly trails before catching up; drag slowly and it tracks almost exactly. Because the spring's own velocity state at any instant already equals the divider's real motion, no separate pointer-velocity sampling (dx/dt across pointermove events) is needed — the carried spring velocity IS the release velocity. PHASE 2, SETTLING: on pointerup, that carried velocity feeds directly into a second spring aimed at whichever user-defined detent (default [25, 50, 75]) is numerically nearest, critically damped (k=170 s^-2, zeta=1, so it closes in without ever overshooting past the stop). That settle only runs if the release was decisive: either the release point is already within 6 percentage points of a detent, or the carried speed is at least 55%/s. Released slowly and far from any stop, the divider simply stays exactly where it was let go — not every release is entitled to land on a rail, and forcing one would fight the user's evident intent to park it off-detent. Both phases share one requestAnimationFrame loop and write only two things per frame: the start pane's flex-basis percentage (style.flexBasis set directly via ref, bypassing React state on the hot path) and the divider's own aria-valuenow/aria-valuetext attributes; the end pane is never touched directly, it fills the remainder via flex:1 and resizes as a pure consequence of the start pane's basis changing. GEOMETRY: pointer position converts to a percentage via the container's own getBoundingClientRect() read fresh on every pointermove (clientX for a vertical divider, clientY for horizontal) — no ResizeObserver or cached width needed, since percentage-based flex-basis is inherently responsive to container resizes on its own. Faint tick marks (var(--ns-muted), ~45% opacity, 1px x 8px) render at each reachable detent as small rail marks fixed near the container's leading edge, independent of the divider's current position, so the snap points read as a property of the track itself and are visible before the very first touch. A11Y: the divider is a real, tabbable role=\"separator\" with aria-orientation matching its axis and aria-valuenow/aria-valuemin/aria-valuemax expressed as a 0-100 percentage of the start pane's share, plus aria-valuetext spelling out both sides ('42% / 58%') so a screen reader hears the whole split, not a bare number. ArrowLeft/Right (vertical) or ArrowUp/Down (horizontal) move the boundary 1 percentage point instantly, no spring — a single discrete nudge doesn't need inertia. Shift+Arrow jumps directly to the next detent in that direction using the same settle spring the pointer path uses (zero initial velocity). Enter toggles between 50/50 and wherever the divider last sat off-center (tracked in a ref, not committed to state) rather than only ever going to center, so repeated Enter presses actually toggle. Double-click glides confidently to 50/50 via the identical settle spring. REDUCED MOTION: both spring phases are skipped outright — dragging tracks the pointer's exact percentage every frame with no lag, and a release only ever produces an instant jump (no coast) to the nearest detent, and only when that release already falls within the same 6-point proximity zone; otherwise it simply stays at the exact released position. VISUAL: the divider's inner bar is 1px var(--border) at rest, brightens toward a border/foreground color-mix on hover or focus-visible, and thickens to 2px at full var(--foreground) while actively dragged; a focus-visible-gated accent ring (checked via :focus-visible so a pointer-issued focus never paints it) marks keyboard focus and active drag without altering the thickness logic. No canvas — pure DOM, CSS transforms, and getComputedStyle-free token classes throughout. Controlled (value + onValueChange, percentage of the start pane) or uncontrolled (defaultValue) API; cleans up its rAF on unmount. It differs from slider-range-shear, a dual-thumb range slider whose selected SPAN visibly shears under drag velocity: split-pane-weighted owns exactly one moving boundary between two arbitrary pane contents, not a value range, and its velocity-awareness is expressed as detent-seeking coast-and-settle rather than a visible material lean — no taken component in this registry owns pane resizing via flex-basis. DEMO: a three-pane IDE-style layout (file tree, code editor, live preview) built from two nested SashWeight instances — an outer vertical divider between the file tree and the rest, and an inner horizontal divider between the editor and the preview — so both orientations and both instances' independent detent sets are exercised in one composition."
  },
  "type": "registry:ui"
}