Split Pane Weighted

Divider

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.

Install
npx shadcn add https://design.helpmarq.com/r/split-pane-weighted.json
Source
registry/core/split-pane-weighted/component.tsx
"use client";

import { useLayoutEffect, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// SashWeight — a split-pane divider that behaves like a counterweighted
// window sash rather than a bare drag handle. Two phases of one spring
// simulation share a single rAF loop:
//
//   1. DRAGGING: while the pointer is down, the divider does not snap to the
//      pointer 1:1 — the live position chases the pointer through an
//      overdamped spring (k=90 s^-2, zeta=1.15). That lag IS the weight: a
//      fast flick visibly trails before catching up, a slow drag tracks it
//      almost exactly. The spring's own velocity at any instant is therefore
//      already the divider's real velocity — no separate pointer-velocity
//      sampling is needed.
//   2. SETTLING: on release, that carried velocity feeds directly into a
//      critically-damped spring (k=170 s^-2, zeta=1, no overshoot) aimed at
//      the nearest detent — but only if the release was fast (>=55%/s) or
//      already close to a detent (<=6%). Released slow and far from a stop,
//      the sash simply stays where it was let go; not every release is
//      supposed to land on a rail.
//
// Only the two panes are ever written to imperatively (flex-basis on the
// start pane; the end pane fills the remainder via flex:1, so it "resizes"
// without a second write) plus the divider's own ARIA value attributes —
// nothing else in the tree is touched by the animation loop, and no React
// state re-render drives a single frame of it.
//
// A11y: the divider is a real, focusable role="separator" (not a plain
// draggable div) with aria-orientation and aria-valuenow/min/max as a
// percentage, so a screen reader reports it as a moveable boundary, not a
// static rule. Arrow keys move 1% (instant, no spring — a discrete nudge
// doesn't need inertia); Shift+Arrow jumps to the next detent in that
// direction via the same settle spring the pointer path uses; Enter toggles
// between 50/50 and wherever the sash last sat off-center. prefers-reduced-
// motion strips both spring phases: dragging tracks the pointer exactly,
// and a release only ever snaps instantly (no coast) when it lands inside
// the same detent-proximity zone.
// ---------------------------------------------------------------------------

const HIT = 8; // px width/height of the divider's pointer hit area
const DRAG_K = 90; // s^-2 — spring the live position chases the pointer with
const DRAG_ZETA = 1.15; // overdamped: trails a fast pointer, never oscillates
const SETTLE_K = 170; // s^-2 — release spring toward the nearest detent
const SETTLE_ZETA = 1; // critically damped: closes in without overshoot
const SNAP_ZONE = 6; // percent — release within this of a detent always settles
const VEL_THRESHOLD = 55; // percent/s — release faster than this always coasts+settles
const EPS_POS = 0.05; // percent
const EPS_VEL = 0.4; // percent/s
const CENTER_EPS = 0.6; // percent — how close to 50 counts as "already centered"

type Orientation = "vertical" | "horizontal";
type Phase = "idle" | "dragging" | "settling";

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

export interface SashWeightProps {
  /** "vertical" = a vertical divider splitting left/right panes (default). "horizontal" = a horizontal divider splitting top/bottom panes. */
  orientation?: Orientation;
  /** the leading (left / top) pane's content */
  start: React.ReactNode;
  /** the trailing (right / bottom) pane's content */
  end: React.ReactNode;
  /** controlled: the start pane's share of the container, as a percentage */
  value?: number;
  defaultValue?: number;
  onValueChange?: (percent: number) => void;
  /** percentage clamp for the divider's travel */
  min?: number;
  max?: number;
  /** percentages the divider settles toward on a decisive release, Shift+Arrow, or Enter/double-click's 50/50 fallback */
  detents?: number[];
  /** accessible name for the divider */
  ariaLabel?: string;
  className?: string;
}

export function SashWeight({
  orientation = "vertical",
  start,
  end,
  value,
  defaultValue = 50,
  onValueChange,
  min = 10,
  max = 90,
  detents = [25, 50, 75],
  ariaLabel = "Resize panes",
  className = "",
}: SashWeightProps) {
  const isControlled = value !== undefined;
  const [posState, setPosState] = useState(() => clamp(defaultValue, min, max));
  const pos = isControlled ? clamp(value, min, max) : posState;

  const [dragging, setDragging] = useState(false);
  const [hovering, setHovering] = useState(false);
  const [focused, setFocused] = useState(false);

  const containerRef = useRef<HTMLDivElement>(null);
  const startPaneRef = useRef<HTMLDivElement>(null);
  const dividerRef = useRef<HTMLDivElement>(null);

  const minRef = useRef(min);
  minRef.current = min;
  const maxRef = useRef(max);
  maxRef.current = max;
  const detentsRef = useRef(detents);
  detentsRef.current = detents;
  const orientationRef = useRef(orientation);
  orientationRef.current = orientation;

  const posRef = useRef(pos); // last committed value
  posRef.current = pos;
  const liveRef = useRef(pos); // current animated / dragged value
  const velRef = useRef(0);
  const dragTargetRef = useRef(pos);
  const settleTargetRef = useRef(pos);
  const phaseRef = useRef<Phase>("idle");
  const rafRef = useRef(0);
  const lastRef = useRef(0);
  const reducedRef = useRef(false);
  const lastOffCenterRef = useRef<number | null>(null);

  useLayoutEffect(() => {
    reducedRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    return () => {
      if (rafRef.current) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  const writeDOM = (p: number) => {
    if (startPaneRef.current) startPaneRef.current.style.flexBasis = `${p}%`;
    const rounded = Math.round(p);
    const el = dividerRef.current;
    if (el) {
      el.setAttribute("aria-valuenow", String(rounded));
      el.setAttribute("aria-valuetext", `${rounded}% / ${100 - rounded}%`);
    }
  };

  // keep the DOM in sync with the committed value whenever nothing is
  // actively animating it (initial mount, and controlled updates from outside)
  useLayoutEffect(() => {
    if (phaseRef.current === "idle") {
      liveRef.current = pos;
      writeDOM(pos);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [pos]);

  const commit = (p: number) => {
    const rounded = Number(p.toFixed(2));
    posRef.current = rounded;
    if (!isControlled) setPosState(rounded);
    onValueChange?.(rounded);
  };

  const nearestDetent = (p: number) => {
    const list = detentsRef.current;
    if (list.length === 0) return p;
    return list.reduce((best, d) => (Math.abs(d - p) < Math.abs(best - p) ? d : best), list[0]);
  };

  const cancelRaf = () => {
    if (rafRef.current) {
      cancelAnimationFrame(rafRef.current);
      rafRef.current = 0;
    }
  };

  const tick = (now: number) => {
    const dt = Math.min(0.032, Math.max(0, (now - lastRef.current) / 1000));
    lastRef.current = now;
    const lo = minRef.current;
    const hi = maxRef.current;

    if (phaseRef.current === "dragging") {
      const c = 2 * Math.sqrt(DRAG_K) * DRAG_ZETA;
      const target = dragTargetRef.current;
      const accel = -DRAG_K * (liveRef.current - target) - c * velRef.current;
      velRef.current += accel * dt;
      liveRef.current = clamp(liveRef.current + velRef.current * dt, lo, hi);
      writeDOM(liveRef.current);
      rafRef.current = requestAnimationFrame(tick);
      return;
    }

    if (phaseRef.current === "settling") {
      const c = 2 * Math.sqrt(SETTLE_K) * SETTLE_ZETA;
      const target = settleTargetRef.current;
      const accel = -SETTLE_K * (liveRef.current - target) - c * velRef.current;
      velRef.current += accel * dt;
      liveRef.current = clamp(liveRef.current + velRef.current * dt, lo, hi);
      writeDOM(liveRef.current);
      if (Math.abs(liveRef.current - target) < EPS_POS && Math.abs(velRef.current) < EPS_VEL) {
        liveRef.current = target;
        velRef.current = 0;
        writeDOM(target);
        commit(target);
        phaseRef.current = "idle";
        rafRef.current = 0;
        return;
      }
      rafRef.current = requestAnimationFrame(tick);
      return;
    }
  };

  const ensureRaf = () => {
    if (!rafRef.current) {
      lastRef.current = performance.now();
      rafRef.current = requestAnimationFrame(tick);
    }
  };

  const startSettle = (target: number, initialVel: number) => {
    settleTargetRef.current = clamp(target, minRef.current, maxRef.current);
    velRef.current = initialVel;
    phaseRef.current = "settling";
    ensureRaf();
  };

  const pointerToPercent = (e: { clientX: number; clientY: number }) => {
    const rect = containerRef.current!.getBoundingClientRect();
    const frac =
      orientationRef.current === "vertical"
        ? (e.clientX - rect.left) / Math.max(1, rect.width)
        : (e.clientY - rect.top) / Math.max(1, rect.height);
    return clamp(frac * 100, minRef.current, maxRef.current);
  };

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    dividerRef.current?.setPointerCapture(e.pointerId);
    cancelRaf();
    setDragging(true);
    liveRef.current = posRef.current;
    velRef.current = 0;
    dragTargetRef.current = pointerToPercent(e);
    phaseRef.current = "dragging";
    // under reduced motion, onPointerMove writes the position directly and
    // no spring ever runs, so no rAF loop is needed for the drag phase
    if (!reducedRef.current) ensureRaf();
    e.preventDefault();
  };

  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (phaseRef.current !== "dragging") return;
    if (reducedRef.current) {
      liveRef.current = pointerToPercent(e);
      writeDOM(liveRef.current);
      return;
    }
    dragTargetRef.current = pointerToPercent(e);
  };

  const releaseDrag = (e: React.PointerEvent<HTMLDivElement>) => {
    if (phaseRef.current !== "dragging") return;
    try {
      dividerRef.current?.releasePointerCapture(e.pointerId);
    } catch {
      /* pointer already released */
    }
    setDragging(false);
    const current = liveRef.current;
    const nearest = nearestDetent(current);
    const dist = Math.abs(current - nearest);

    if (reducedRef.current) {
      const final = dist <= SNAP_ZONE ? nearest : current;
      liveRef.current = final;
      velRef.current = 0;
      phaseRef.current = "idle";
      writeDOM(final);
      commit(final);
      cancelRaf();
      return;
    }

    const speed = Math.abs(velRef.current);
    if (dist <= SNAP_ZONE || speed >= VEL_THRESHOLD) {
      startSettle(nearest, velRef.current);
    } else {
      phaseRef.current = "idle";
      cancelRaf();
      commit(current);
    }
  };

  const goTo = (target: number) => {
    const clamped = clamp(target, minRef.current, maxRef.current);
    cancelRaf();
    if (reducedRef.current) {
      liveRef.current = clamped;
      phaseRef.current = "idle";
      writeDOM(clamped);
      commit(clamped);
    } else {
      liveRef.current = posRef.current;
      startSettle(clamped, 0);
    }
  };

  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    const vertical = orientationRef.current === "vertical";
    const decKey = vertical ? "ArrowLeft" : "ArrowUp";
    const incKey = vertical ? "ArrowRight" : "ArrowDown";

    if (e.key === decKey || e.key === incKey) {
      e.preventDefault();
      const dir = e.key === incKey ? 1 : -1;
      if (e.shiftKey) {
        const sorted = [...detentsRef.current].sort((a, b) => a - b);
        const current = posRef.current;
        const target =
          dir > 0
            ? (sorted.find((d) => d > current + 0.01) ?? maxRef.current)
            : ([...sorted].reverse().find((d) => d < current - 0.01) ?? minRef.current);
        goTo(target);
      } else {
        cancelRaf();
        phaseRef.current = "idle";
        const target = clamp(posRef.current + dir, minRef.current, maxRef.current);
        liveRef.current = target;
        writeDOM(target);
        commit(target);
      }
      return;
    }

    if (e.key === "Enter") {
      e.preventDefault();
      const current = posRef.current;
      const centered = Math.abs(current - 50) < CENTER_EPS;
      if (centered && lastOffCenterRef.current != null) {
        goTo(lastOffCenterRef.current);
      } else {
        lastOffCenterRef.current = current;
        goTo(50);
      }
    }
  };

  const onDoubleClick = () => {
    if (phaseRef.current === "dragging") return;
    const current = posRef.current;
    if (Math.abs(current - 50) >= CENTER_EPS) lastOffCenterRef.current = current;
    goTo(50);
  };

  const vertical = orientation === "vertical";
  const engaged = dragging || focused;
  const visibleDetents = detents.filter((d) => d >= min && d <= max);

  const dividerBarColor = dragging
    ? "var(--foreground)"
    : hovering || focused
      ? "color-mix(in srgb, var(--border) 35%, var(--foreground) 65%)"
      : "var(--border)";

  return (
    <div
      ref={containerRef}
      data-split-pane-weighted-track
      className={`relative flex h-full w-full ${vertical ? "flex-row" : "flex-col"} ${className}`}
    >
      {/* faint rail marks at each reachable detent — self-explaining before first touch */}
      {visibleDetents.map((d) => (
        <span
          key={d}
          aria-hidden
          className="pointer-events-none absolute bg-muted"
          style={
            vertical
              ? { left: `${d}%`, top: 0, width: 1, height: 8, opacity: 0.45, transform: "translateX(-50%)" }
              : { top: `${d}%`, left: 0, height: 1, width: 8, opacity: 0.45, transform: "translateY(-50%)" }
          }
        />
      ))}

      <div ref={startPaneRef} className="min-h-0 min-w-0 shrink-0 grow-0 overflow-auto" style={{ flexBasis: `${pos}%` }}>
        {start}
      </div>

      <div
        ref={dividerRef}
        role="separator"
        aria-orientation={vertical ? "vertical" : "horizontal"}
        aria-label={ariaLabel}
        aria-valuenow={Math.round(pos)}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuetext={`${Math.round(pos)}% / ${100 - Math.round(pos)}%`}
        tabIndex={0}
        className={`relative z-10 shrink-0 grow-0 touch-none select-none outline-none focus-visible:z-20 ${
          vertical ? "cursor-col-resize" : "cursor-row-resize"
        }`}
        style={{ width: vertical ? HIT : "100%", height: vertical ? "100%" : HIT }}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={releaseDrag}
        onPointerCancel={releaseDrag}
        onPointerEnter={() => setHovering(true)}
        onPointerLeave={() => setHovering(false)}
        onFocus={(e) => {
          if (e.target.matches(":focus-visible")) setFocused(true);
        }}
        onBlur={() => setFocused(false)}
        onKeyDown={onKeyDown}
        onDoubleClick={onDoubleClick}
      >
        <span
          aria-hidden
          className="pointer-events-none absolute rounded-full transition-[background-color,width,height] duration-150 ease-out motion-reduce:transition-none"
          style={
            vertical
              ? {
                  left: "50%",
                  top: 0,
                  bottom: 0,
                  width: dragging ? 2 : 1,
                  transform: "translateX(-50%)",
                  backgroundColor: dividerBarColor,
                }
              : {
                  top: "50%",
                  left: 0,
                  right: 0,
                  height: dragging ? 2 : 1,
                  transform: "translateY(-50%)",
                  backgroundColor: dividerBarColor,
                }
          }
        />
        {engaged && (
          <span
            aria-hidden
            className="pointer-events-none absolute rounded-full ring-2 ring-accent"
            style={
              vertical
                ? { left: "50%", top: "50%", width: HIT, height: 40, transform: "translate(-50%, -50%)" }
                : { top: "50%", left: "50%", height: HIT, width: 40, transform: "translate(-50%, -50%)" }
            }
          />
        )}
      </div>

      <div className="min-h-0 min-w-0 flex-1 overflow-auto">{end}</div>
    </div>
  );
}
Build spec

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(--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.

Tags
dividersplit-paneresizelayoutdragspringseparatorkeyboard