Skip to main content

ns-ui

Split Pane Weighted

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.

Usage

registry/core/split-pane-weighted/demo.tsx
"use client";

import { useState } from "react";
import { SashWeight } from "./component";

const FILES = [
  { name: "src/", dir: true, depth: 0 },
  { name: "app/", dir: true, depth: 1 },
  { name: "layout.tsx", dir: false, depth: 2 },
  { name: "page.tsx", dir: false, depth: 2 },
  { name: "components/", dir: true, depth: 1 },
  { name: "split-pane-weighted.tsx", dir: false, depth: 2, active: true },
  { name: "toolbar.tsx", dir: false, depth: 2 },
  { name: "lib/", dir: true, depth: 1 },
  { name: "spring.ts", dir: false, depth: 2 },
  { name: "package.json", dir: false, depth: 0 },
];

const CODE_LINES = [
  "export function SashWeight({",
  "  orientation = \"vertical\",",
  "  detents = [25, 50, 75],",
  "  ...props",
  "}: SashWeightProps) {",
  "  const live = useSpring(pos, {",
  "    k: dragging ? 90 : 170,",
  "    zeta: dragging ? 1.15 : 1,",
  "  });",
  "",
  "  return <Divider {...live} />;",
  "}",
];

export default function SashWeightDemo() {
  const [outer, setOuter] = useState(28);
  const [inner, setInner] = useState(62);

  return (
    <div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / split-pane-weighted — drag has weight, release finds a rail
      </p>

      <div className="h-[420px] w-full max-w-3xl overflow-hidden rounded-md border border-border bg-surface">
        <SashWeight
          orientation="vertical"
          value={outer}
          onValueChange={setOuter}
          min={16}
          max={60}
          detents={[20, 33, 50]}
          ariaLabel="Resize file tree and editor"
          className="sash-outer"
          start={
            <div className="h-full overflow-y-auto bg-background px-3 py-3">
              <p className="mb-2 px-1 font-mono text-[10px] uppercase tracking-wider text-ns-muted">
                Explorer
              </p>
              <ul>
                {FILES.map((f) => (
                  <li
                    key={f.name}
                    className={`truncate rounded-sm px-1.5 py-1 font-mono text-xs ${
                      f.active ? "bg-border text-foreground" : f.dir ? "text-foreground" : "text-ns-muted"
                    }`}
                    style={{ paddingLeft: 6 + f.depth * 12 }}
                  >
                    {f.name}
                  </li>
                ))}
              </ul>
            </div>
          }
          end={
            <SashWeight
              orientation="horizontal"
              value={inner}
              onValueChange={setInner}
              min={20}
              max={85}
              detents={[25, 50, 75]}
              ariaLabel="Resize editor and preview"
              start={
                <div className="h-full overflow-y-auto bg-background px-4 py-3 font-mono text-xs leading-6 text-foreground">
                  {CODE_LINES.map((line, i) => (
                    <div key={i} className="flex gap-3">
                      <span className="w-5 shrink-0 text-right text-ns-muted">{i + 1}</span>
                      <span className="whitespace-pre">{line || " "}</span>
                    </div>
                  ))}
                </div>
              }
              end={
                <div className="flex h-full flex-col items-center justify-center gap-3 bg-background px-4 py-3">
                  <p className="font-mono text-[10px] uppercase tracking-wider text-ns-muted">
                    Preview
                  </p>
                  <div className="w-full max-w-[220px] rounded-md border border-border bg-surface p-4">
                    <div className="mb-2 h-2 w-2/3 rounded-full bg-ns-muted opacity-40" />
                    <div className="mb-1 h-2 w-full rounded-full bg-ns-muted opacity-20" />
                    <div className="h-2 w-4/5 rounded-full bg-ns-muted opacity-20" />
                    <div className="mt-3 h-7 w-20 rounded-sm bg-ns-accent" />
                  </div>
                </div>
              }
            />
          }
        />
      </div>

      <p className="font-mono text-[11px] text-ns-muted">
        drag either rail fast, or nudge it with Arrow keys — Enter jumps to 50/50
      </p>
    </div>
  );
}

Component

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;
  /** uncontrolled initial share, as a percentage */
  defaultValue?: number;
  /** called with the new percentage on every drag/keyboard change */
  onValueChange?: (percent: number) => void;
  /** percentage clamp for the divider's travel */
  min?: number;
  /** percentage clamp for the divider's travel */
  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;
  /** extra classes merged onto the rendered root element */
  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-ns-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-ns-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>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/split-pane-weighted.tsx, so point the import there.

Use when a resizable split where the divider should carry weight, trailing the pointer like a counterweighted sash and coasting into the nearest detent when released with speed but staying put when released slow and far from a stop; pick drawer-counterweight instead when the panel is an overlay drawer rather than a pane in the layout, or view-toggle-rails when the layout should switch between arrangements rather than be resized.

Install

npx shadcn add https://design.helpmarq.com/r/split-pane-weighted.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

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

Props

PropTypeDefaultDescription
orientation?Orientation"vertical""vertical" = a vertical divider splitting left/right panes (default). "horizontal" = a horizontal divider splitting top/bottom panes.
startReact.ReactNode—the leading (left / top) pane's content
endReact.ReactNode—the trailing (right / bottom) pane's content
value?number—controlled: the start pane's share of the container, as a percentage
defaultValue?number50uncontrolled initial share, as a percentage
onValueChange?(percent: number) => void—called with the new percentage on every drag/keyboard change
min?number10percentage clamp for the divider's travel
max?number90percentage clamp for the divider's travel
detents?number[][25, 50, 75]percentages the divider settles toward on a decisive release, Shift+Arrow, or Enter/double-click's 50/50 fallback
ariaLabel?string"Resize panes"accessible name for the divider
className?string—extra classes merged onto the rendered root element