Skip to main content

ns-ui

Drawer Counterweight

Side drawer hung on a counterweight like a sash window: a thin track on the drawer's leading edge carries a weight pill that travels opposite the drag at -0.6x, and wherever it sits at release (before or past the tick) decides whether a spring pulls the drawer shut or bottoms the weight out and holds it open.

Usage

registry/core/drawer-counterweight/demo.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { SashWeight } from "./component";

// Rest state is gated on the same `embed=1&autoplay=1` pair as the reopen
// loop below. The honest `/preview/drawer-counterweight` reference rests CLOSED — the
// drawer opens only on deliberate interaction (the Filters trigger, Escape,
// a drag) and stays closed once closed, which is exactly what the owner
// asked for ("open only on deliberate interaction"). Only the landing-page
// *autoplay card* rests OPEN, because a closed drawer hides the entire point
// of the component (the weight's resting position IS the state indicator) and
// the card has no real cursor to open it with; its `press` autoplay closes
// the drawer and the gated timer reopens it, so the weight-drop keeps
// demonstrating on a loop rather than going stone-still.
//
// That reopen must only fire for the landing-page card. Same bug as
// input-focus-membrane's autoplay-only seed value (see its demo.tsx comment): this
// component's own local demo state was reopening the drawer unconditionally,
// including on the plain `/preview/drawer-counterweight` reference with no query params
// at all — the honest, fully-interactive view every real owner/consumer
// actually uses. Closing the drawer there (Apply, the close button, Escape,
// backdrop, or a drag-release toward closed) got silently undone ~1.8s later,
// which reads exactly like "the filter drawer always reopens / can't be
// closed" — because, unconditionally, it was. Gating on the same
// `embed=1&autoplay=1` pair page.tsx uses to decide whether the driver even
// mounts (see app/preview/[name]/page.tsx) confines the loop to the one
// context it's meant to demonstrate.
const REOPEN_MS = 1800;

const CATEGORIES = ["Lighting", "Seating", "Storage", "Textiles", "Outdoor"];

export default function SashWeightDemo() {
  const searchParams = useSearchParams();
  const autoplaying =
    searchParams.get("embed") === "1" && searchParams.get("autoplay") === "1";

  const [open, setOpen] = useState(autoplaying);
  const [checked, setChecked] = useState<Record<string, boolean>>({
    Lighting: true,
    Seating: true,
  });
  const [sort, setSort] = useState("relevance");
  const reopenRef = useRef(0);

  useEffect(() => () => window.clearTimeout(reopenRef.current), []);

  const handleOpenChange = (next: boolean) => {
    window.clearTimeout(reopenRef.current);
    setOpen(next);
    if (!next && autoplaying) {
      reopenRef.current = window.setTimeout(() => setOpen(true), REOPEN_MS);
    }
  };

  const toggle = (label: string) =>
    setChecked((prev) => ({ ...prev, [label]: !prev[label] }));

  const activeCount = Object.values(checked).filter(Boolean).length;

  return (
    <div className="flex min-h-screen flex-col items-center gap-8 px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / drawer-counterweight — drag the rail, watch the weight decide
      </p>

      <div className="w-full max-w-3xl rounded-md border border-border bg-surface">
        <div className="flex flex-wrap items-center gap-3 border-b border-border px-5 py-4">
          <div className="min-w-0 flex-1">
            <h2 className="text-sm font-semibold text-foreground">Catalog</h2>
            <p className="mt-0.5 font-mono text-xs text-ns-muted">
              128 items · sorted by {sort === "relevance" ? "relevance" : "price"}
            </p>
          </div>
          <input
            type="search"
            placeholder="Search catalog…"
            className="w-40 rounded-sm border border-border bg-background px-3 py-1.5 text-sm text-foreground placeholder:text-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent sm:w-56"
          />
          <SashWeight
            open={open}
            onOpenChange={handleOpenChange}
            trigger={
              <>
                Filters
                {activeCount > 0 && (
                  <span className="rounded-full border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-ns-muted">
                    {activeCount}
                  </span>
                )}
              </>
            }
            title="Filters"
            description="Narrow the catalog by category and price."
          >
            <div className="flex flex-col gap-6">
              <fieldset className="flex flex-col gap-2.5">
                <legend className="mb-1 font-mono text-[11px] uppercase tracking-wider text-ns-muted">
                  Category
                </legend>
                {CATEGORIES.map((c) => (
                  <label key={c} className="group flex cursor-pointer items-center gap-2.5">
                    <span className="relative inline-flex h-4 w-4 shrink-0">
                      <input
                        type="checkbox"
                        checked={checked[c] ?? false}
                        onChange={() => toggle(c)}
                        className="peer h-4 w-4 cursor-pointer appearance-none rounded-[4px] border border-border bg-transparent transition-colors duration-150 checked:border-foreground checked:bg-foreground hover:border-foreground/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                      />
                      <svg
                        aria-hidden
                        viewBox="0 0 12 12"
                        fill="none"
                        className="pointer-events-none absolute inset-0 m-auto h-3 w-3 text-background opacity-0 transition-opacity duration-150 peer-checked:opacity-100"
                      >
                        <path
                          d="M2.5 6.5 5 9l4.5-5.5"
                          stroke="currentColor"
                          strokeWidth="1.8"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                        />
                      </svg>
                    </span>
                    <span className="text-[13px] text-foreground">{c}</span>
                  </label>
                ))}
              </fieldset>

              <div className="flex flex-col gap-2">
                <label htmlFor="sash-sort" className="font-mono text-[11px] uppercase tracking-wider text-ns-muted">
                  Sort by
                </label>
                <select
                  id="sash-sort"
                  value={sort}
                  onChange={(e) => setSort(e.target.value)}
                  className="rounded-sm border border-border bg-background px-2.5 py-1.5 text-sm text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  <option value="relevance">Relevance</option>
                  <option value="price-asc">Price: low to high</option>
                  <option value="price-desc">Price: high to low</option>
                </select>
              </div>

              <div className="mt-1 flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => handleOpenChange(false)}
                  className="rounded-sm bg-ns-accent px-4 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  Apply filters
                </button>
                <button
                  type="button"
                  onClick={() => setChecked({})}
                  className="rounded-sm border border-border px-4 py-2 text-sm font-medium text-foreground transition-colors duration-200 hover:border-foreground/25 hover:bg-foreground/5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
                >
                  Reset
                </button>
              </div>
            </div>
          </SashWeight>
        </div>

        <div className="grid grid-cols-2 gap-3 p-5 sm:grid-cols-3">
          {Array.from({ length: 9 }).map((_, i) => (
            <div
              key={i}
              className="flex flex-col gap-2 rounded-sm border border-border bg-background p-3"
            >
              <div className="h-16 rounded-sm border border-border bg-foreground/[0.04]" />
              <p className="text-xs text-foreground">Item {i + 1}</p>
              <p className="font-mono text-[11px] text-ns-muted">${(29 + i * 6).toFixed(2)}</p>
            </div>
          ))}
        </div>
      </div>

      <p className="max-w-md text-center text-xs text-ns-muted">
        Grab the thin rail on the drawer's left edge — the weight travels
        opposite your drag at 0.6x. Release above the tick and it pulls the
        drawer shut; past it, the weight bottoms out and the drawer holds
        itself open. The trigger and Escape work the same spring without a
        drag.
      </p>
    </div>
  );
}

Component

registry/core/drawer-counterweight/component.tsx
"use client";

import { useEffect, useId, useRef, useState, type ReactNode } from "react";

// ---------------------------------------------------------------------------
// SashWeight — a side drawer hung on a counterweight, sash-window style. The
// drawer's leading edge carries a thin vertical track; a 24px weight pill
// rides it, traveling opposite the drawer at a -0.6x ratio while the drawer
// is dragged, plus a tick marking the balance point. Release before the tick
// (weight still above it) and a spring pulls the drawer shut; release past it
// (weight bottoms out) and the drawer holds itself open — the weight's
// resting position (down = open, up = closed) is the state indicator, true
// even when nobody is mid-drag. Built on the native <dialog> element for
// showModal()'s free focus trap, Escape-as-cancel, top-layer stacking and
// background inertness; only the entrance/exit motion and the counterweight
// are hand-rolled. All motion (drawer translateX, weight translateY, scrim
// opacity) is driven by a direct-DOM rAF loop writing CSS custom properties —
// zero React state on the hot path. Two spring profiles (opening: k=260
// zeta≈0.74 / closing: k=260 zeta≈0.93, mass 1) resolve the drawer+weight
// together toward whichever side the release lands on, seeded with the
// actual release velocity so the catch reads as one continuous motion, not a
// snap-then-glide. Reduced motion drops both springs for a 150ms opacity fade
// and disables weight travel — it appears already parked at the resting spot.
// ---------------------------------------------------------------------------

const WEIGHT_SIZE = 24; // px — the pill
const TRACK_LEN = 200; // px — visual track length
const TRAVEL = TRACK_LEN - WEIGHT_SIZE; // px of usable weight travel
const BALANCE_FRAC = 0.5; // the tick's position along the travel
const BALANCE_Y = BALANCE_FRAC * TRAVEL;
const RATIO = 0.6; // weight moves at -0.6x the drawer's drag delta

const OPEN_K = 260;
const OPEN_C = 24;
const CLOSE_K = 260;
const CLOSE_C = 30;
const FLING_V = 500; // px/s — release speed past this overrides tick position
const V_WINDOW = 80; // ms of pointer samples averaged into release velocity
const SETTLE_MS = 1000; // forced-settle deadline
const POS_EPS = 0.4;
const VEL_EPS = 6;
const FADE_MS = 150; // reduced-motion opacity fade

function clamp(v: number, lo: number, hi: number): number {
  return v < lo ? lo : v > hi ? hi : v;
}

export interface SashWeightProps {
  /** controlled open state; omit for uncontrolled */
  open?: boolean;
  /** uncontrolled initial open state. Default false. */
  defaultOpen?: boolean;
  /** called with the new open state after the trigger or a dismissal */
  onOpenChange?: (open: boolean) => void;
  /** label rendered on the trigger button */
  trigger?: ReactNode;
  /** drawer heading */
  title: string;
  /** supporting copy under the title */
  description?: ReactNode;
  /** drawer body content */
  children?: ReactNode;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function SashWeight({
  open,
  defaultOpen = false,
  onOpenChange,
  trigger = "Open panel",
  title,
  description,
  children,
  className = "",
}: SashWeightProps) {
  const isControlled = open !== undefined;
  const [internalOpen, setInternalOpen] = useState(defaultOpen);
  const openState = isControlled ? (open as boolean) : internalOpen;

  const dialogRef = useRef<HTMLDialogElement>(null);
  const railRef = useRef<HTMLDivElement>(null);
  const weightRef = useRef<HTMLSpanElement>(null);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const liveRef = useRef<HTMLParagraphElement>(null);
  const engineRef = useRef<{ request: (target: boolean, v?: number) => void } | null>(null);

  const titleId = useId();
  const descId = useId();
  const dialogId = useId();

  const notify = (next: boolean) => {
    if (!isControlled) setInternalOpen(next);
    onOpenChange?.(next);
  };
  // the setup effect below mounts once; its pointer/keyboard handlers must
  // still call whatever `onOpenChange` the *latest* render passed in, not
  // the one captured when the effect first ran — mirrored every render.
  const notifyRef = useRef(notify);
  notifyRef.current = notify;

  useEffect(() => {
    const dlg = dialogRef.current;
    const rail = railRef.current;
    const weight = weightRef.current;
    const live = liveRef.current;
    if (!dlg || !rail || !weight || !live) return;

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;
    const onMq = () => {
      reduced = mq.matches;
    };
    mq.addEventListener("change", onMq);

    // -- hot-path state: locals only, never React state ----------------------
    let W = 0; // measured drawer width, px
    let sized = false;
    let everOpened = false; // has the dialog been shown at least once yet?
    let tx = 0; // drawer offset from open, 0 = open, W = closed (off-screen)
    let wY = 0; // weight offset within its travel, 0 = top/closed, TRAVEL = bottom/open
    let vTx = 0;
    let vWy = 0;
    let mode: 0 | 1 | 2 = 0; // idle | dragging | spring-resolving
    let targetOpen = openState;
    let pendingTarget: boolean | null = null;
    let deadline = 0;
    let raf = 0;
    let last = 0;
    let fadeTimer = 0;

    // drag bookkeeping
    let pointerId = -1;
    let startPointerX = 0;
    let pointerX = 0;
    let startTx = 0;
    let startWy = 0;
    let samples: { t: number; x: number }[] = [];

    const render = () => {
      dlg.style.setProperty("--sash-tx", `${tx.toFixed(2)}px`);
      weight.style.setProperty("--sash-wy", `${wY.toFixed(2)}px`);
      const progress = W > 0 ? clamp(1 - tx / W, 0, 1) : 0;
      dlg.style.setProperty("--sash-scrim-op", (progress * 0.55).toFixed(3));
    };

    const announce = (isOpen: boolean) => {
      live.textContent = isOpen ? `${title} opened` : `${title} closed`;
    };

    const resize = () => {
      const rect = dlg.getBoundingClientRect();
      if (rect.width < 40) {
        sized = false;
        return;
      }
      const prevW = W;
      W = rect.width;
      sized = true;
      // re-anchor to whichever end is currently settled when the viewport
      // itself resizes — but never on the very first measurement (that one
      // is the bootstrap for a fresh spring-open, and everOpened being false
      // there keeps this from snapping tx to its target before the spring in
      // request() ever gets a displacement to resolve).
      if (mode === 0 && everOpened && W !== prevW) {
        tx = targetOpen ? 0 : W;
        render();
      }
      if (pendingTarget !== null) {
        const t = pendingTarget;
        pendingTarget = null;
        request(t, 0);
      }
    };

    const fadeTo = (target: boolean) => {
      window.clearTimeout(fadeTimer);
      if (target) {
        dlg.style.transition = "none";
        dlg.style.opacity = "0";
        requestAnimationFrame(() => {
          dlg.style.transition = `opacity ${FADE_MS}ms ease-out`;
          dlg.style.opacity = "1";
        });
      } else {
        dlg.style.transition = `opacity ${FADE_MS}ms ease-out`;
        dlg.style.opacity = "0";
        fadeTimer = window.setTimeout(() => {
          if (dlg.open) dlg.close();
          dlg.style.opacity = "";
          dlg.style.transition = "";
        }, FADE_MS + 20);
      }
    };

    const wake = () => {
      if (!raf && sized && mode !== 0 && !document.hidden) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };

    const finishSettle = () => {
      mode = 0;
      if (!targetOpen && dlg.open) dlg.close();
      announce(targetOpen);
    };

    const loop = (now: number) => {
      raf = 0;
      if (document.hidden || mode === 0) {
        last = 0;
        return;
      }
      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;
      last = now;

      if (mode === 1) {
        const dx = pointerX - startPointerX;
        tx = clamp(startTx + dx, 0, W);
        wY = clamp(startWy - RATIO * dx, 0, TRAVEL);
      } else {
        const k = targetOpen ? OPEN_K : CLOSE_K;
        const c = targetOpen ? OPEN_C : CLOSE_C;
        const tTx = targetOpen ? 0 : W;
        const tWy = targetOpen ? TRAVEL : 0;
        vTx += (-k * (tx - tTx) - c * vTx) * dt;
        tx += vTx * dt;
        vWy += (-k * (wY - tWy) - c * vWy) * dt;
        wY += vWy * dt;

        const settled =
          Math.abs(tx - tTx) < POS_EPS &&
          Math.abs(vTx) < VEL_EPS &&
          Math.abs(wY - tWy) < POS_EPS &&
          Math.abs(vWy) < VEL_EPS;
        if (settled || now >= deadline) {
          tx = tTx;
          wY = tWy;
          vTx = vWy = 0;
          render();
          finishSettle();
          return;
        }
      }
      render();
      raf = requestAnimationFrame(loop);
    };

    // engine.request — single authoritative entry point for every open/close
    // intent (trigger, close button, backdrop, Escape, drag release). Two
    // calls targeting the same in-flight destination collapse into one: only
    // the first supplies a velocity, the second just refreshes the deadline —
    // so a React-effect echo of a decision already dispatched imperatively
    // from a pointerup handler can never stomp its velocity back to zero.
    const request = (target: boolean, v = 0) => {
      if (mode === 2 && targetOpen === target) {
        deadline = performance.now() + SETTLE_MS;
        return;
      }
      if (target) {
        if (!dlg.open) dlg.showModal();
        if (!sized) resize();
        if (!sized) {
          pendingTarget = target;
          return;
        }
        if (!everOpened) {
          // first-ever open: seed the closed origin explicitly so the spring
          // below actually has a displacement to resolve, instead of
          // discovering tx already equal to its own target.
          tx = W;
          wY = 0;
          everOpened = true;
        }
      } else if (!dlg.open) {
        mode = 0;
        return; // already fully closed, nothing to animate
      }

      if (reduced) {
        tx = target ? 0 : W;
        wY = target ? TRAVEL : 0;
        vTx = vWy = 0;
        mode = 0;
        render();
        fadeTo(target);
        announce(target);
        return;
      }

      targetOpen = target;
      vTx = v;
      vWy = -RATIO * v;
      mode = 2;
      deadline = performance.now() + SETTLE_MS;
      wake();
    };

    engineRef.current = { request };

    const onRailDown = (e: PointerEvent) => {
      if (reduced || !dlg.open || mode === 1 || e.button !== 0) return;
      pointerId = e.pointerId;
      startPointerX = e.clientX;
      pointerX = e.clientX;
      startTx = tx;
      startWy = wY;
      samples = [{ t: e.timeStamp, x: e.clientX }];
      mode = 1;
      rail.style.cursor = "grabbing";
      try {
        rail.setPointerCapture(e.pointerId);
      } catch {
        /* pointer already gone */
      }
      wake();
    };

    const onRailMove = (e: PointerEvent) => {
      if (mode !== 1 || e.pointerId !== pointerId) return;
      pointerX = e.clientX;
      samples.push({ t: e.timeStamp, x: e.clientX });
      while (samples.length > 1 && e.timeStamp - (samples[0]?.t ?? 0) > V_WINDOW) {
        samples.shift();
      }
    };

    const releaseDrag = (e: PointerEvent) => {
      if (e.pointerId !== pointerId) return;
      pointerId = -1;
      rail.style.cursor = "";
      if (mode !== 1) return;
      const cutoff = e.timeStamp - V_WINDOW;
      let i0 = 0;
      while (i0 < samples.length - 1 && (samples[i0]?.t ?? 0) < cutoff) i0++;
      const a = samples[i0];
      const b = samples[samples.length - 1];
      let vRelease = 0;
      if (a && b && b.t - a.t > 8) vRelease = ((b.x - a.x) / (b.t - a.t)) * 1000;
      const decision =
        Math.abs(vRelease) > FLING_V ? vRelease < 0 : wY > BALANCE_Y;
      request(decision, vRelease);
      notify(decision);
    };

    const onBackdropDown = (e: PointerEvent) => {
      // backdrop clicks target the dialog itself (::backdrop isn't a real
      // element); a click landing inside the panel targets a descendant.
      if (e.target === dlg) {
        request(false, 0);
        notify(false);
      }
    };
    const onCancel = (e: Event) => {
      e.preventDefault();
      request(false, 0);
      notify(false);
    };
    const onNativeClose = () => {
      // covers any close path that bypassed request() (shouldn't happen, but
      // keeps state honest if something calls dlg.close() directly)
      if (mode !== 0) mode = 0;
    };
    dlg.addEventListener("pointerdown", onBackdropDown);
    dlg.addEventListener("cancel", onCancel);
    dlg.addEventListener("close", onNativeClose);
    rail.addEventListener("pointerdown", onRailDown);
    rail.addEventListener("pointermove", onRailMove);
    rail.addEventListener("pointerup", releaseDrag);
    rail.addEventListener("pointercancel", releaseDrag);

    const ro = new ResizeObserver(resize);
    ro.observe(dlg);
    // Deliberately no IntersectionObserver here: this dialog is
    // position:fixed (viewport-relative, unaffected by page scroll), and the
    // *only* thing that ever changes its on-/off-screen intersection is its
    // own open/close transform — so gating the rAF loop on "currently
    // intersecting" would gate the animation on its own output. A close
    // settles with the panel translated fully off-screen (non-intersecting)
    // in the same frame the spring reaches its target; an IO callback firing
    // on that same closed frame would latch a `visible=false` that nothing
    // could ever clear (the panel can only become intersecting again by
    // running the very animation the flag blocks), silently swallowing or
    // cutting off the next open/close request — which reads as the panel
    // popping partway open/closed and resetting, over and over. Tab
    // backgrounding is still covered, correctly, by `document.hidden` below.
    const onVis = () => {
      if (!document.hidden) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    // no direct kickoff here — the `[openState]` effect below fires on mount
    // too (openState starts defined), and engineRef.current is already wired
    // by the time it runs in the same commit. One call site, no echo to guard.

    return () => {
      cancelAnimationFrame(raf);
      window.clearTimeout(fadeTimer);
      mq.removeEventListener("change", onMq);
      dlg.removeEventListener("pointerdown", onBackdropDown);
      dlg.removeEventListener("cancel", onCancel);
      dlg.removeEventListener("close", onNativeClose);
      rail.removeEventListener("pointerdown", onRailDown);
      rail.removeEventListener("pointermove", onRailMove);
      rail.removeEventListener("pointerup", releaseDrag);
      rail.removeEventListener("pointercancel", releaseDrag);
      ro.disconnect();
      document.removeEventListener("visibilitychange", onVis);
      engineRef.current = null;
    };
    // title feeds the aria-live announcement text but shouldn't retrigger the
    // whole engine setup — read fresh via the closure is fine since it only
    // changes if the caller renames the panel, an edge case not worth a
    // teardown/rebuild of the pointer + observer wiring.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // reacts to externally-controlled `open` prop changes (or the initial
  // uncontrolled value). Internal handlers already drive the engine
  // imperatively and in the same tick, so this mostly matters for a parent
  // flipping `open` without going through this component's own controls —
  // request()'s same-target guard makes a redundant echo harmless.
  useEffect(() => {
    engineRef.current?.request(openState, 0);
  }, [openState]);

  const handleTriggerClick = () => {
    engineRef.current?.request(true, 0);
    notify(true);
  };
  const handleClose = () => {
    engineRef.current?.request(false, 0);
    notify(false);
  };

  return (
    <>
      <dialog
        ref={dialogRef}
        id={dialogId}
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={description ? descId : undefined}
        className={`ns-sash h-dvh max-h-none w-[min(24rem,88vw)] max-w-none overflow-hidden border-l border-border bg-surface p-0 text-foreground shadow-2xl will-change-transform ${className}`}
      >
        <div className="flex h-full">
          {/* leading-edge sash rail: decorative counterweight track, and the
              optional drag handle. aria-hidden — the trigger button already
              exposes open/closed via aria-expanded, and a visible weight
              position conveys nothing to assistive tech. */}
          <div
            ref={railRef}
            aria-hidden
            className="relative flex w-8 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border select-none"
          >
            <div
              className="relative bg-border"
              style={{ height: TRACK_LEN, width: 1 }}
            >
              <span
                className="absolute left-1/2 h-px w-3 -translate-x-1/2 bg-border"
                style={{ top: BALANCE_Y }}
              />
              <span
                ref={weightRef}
                className="absolute left-1/2 top-0 h-6 w-6 -translate-x-1/2 rounded-full border border-border bg-foreground shadow-sm will-change-transform"
                style={{ transform: "translateY(var(--sash-wy, 0px))" }}
              />
            </div>
          </div>

          {/* showModal() focuses the first focusable descendant unless something
              claims it — here that's the close button, which paints a focus
              ring at rest (this demo rests open). Same pattern as
              dialog-emerge: take the focus on the panel itself, so screen
              readers announce the title and Tab starts at the top without
              preselecting a control. */}
          <div autoFocus tabIndex={-1} className="flex min-w-0 flex-1 flex-col outline-none">
            <header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
              <div className="min-w-0">
                <h2 id={titleId} className="text-sm font-semibold text-foreground">
                  {title}
                </h2>
                {description && (
                  <p id={descId} className="mt-1 text-xs leading-relaxed text-ns-muted">
                    {description}
                  </p>
                )}
              </div>
              <button
                type="button"
                onClick={handleClose}
                aria-label="Close"
                className="shrink-0 rounded-sm border border-border p-1.5 text-ns-muted transition-colors duration-150 hover:border-foreground/25 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
              >
                <svg aria-hidden viewBox="0 0 16 16" width="14" height="14" fill="none">
                  <path
                    d="M4 4l8 8M12 4l-8 8"
                    stroke="currentColor"
                    strokeWidth="1.5"
                    strokeLinecap="round"
                  />
                </svg>
              </button>
            </header>
            <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
          </div>
        </div>

        <p ref={liveRef} role="status" aria-live="polite" className="sr-only" />

        <style>{`
          .ns-sash {
            position: fixed;
            inset: 0 0 0 auto;
            transform: translateX(var(--sash-tx, 100%));
          }
          .ns-sash::backdrop {
            background: color-mix(in srgb, var(--foreground) 22%, transparent);
            opacity: var(--sash-scrim-op, 0);
          }
          :where(.dark) .ns-sash::backdrop {
            background: color-mix(in srgb, var(--background) 66%, transparent);
          }
          @media (prefers-reduced-motion: reduce) {
            .ns-sash { transition: none; }
          }
        `}</style>
      </dialog>

      <button
        ref={triggerRef}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={openState}
        aria-controls={dialogId}
        onClick={handleTriggerClick}
        className="inline-flex items-center gap-2 rounded-sm border border-border bg-surface px-3.5 py-2 text-sm font-medium text-foreground transition-colors duration-150 hover:border-foreground/25 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
      >
        {trigger}
      </button>
    </>
  );
}

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

Use when a side drawer the user drags, where the release position should decide the outcome through a visible counterweight rather than a hidden velocity threshold; pick dialog-emerge instead when the overlay is a centered modal opened by a control, or swipe-row-detent when the drag belongs to a list row rather than to a panel.

Install

npx shadcn add https://design.helpmarq.com/r/drawer-counterweight.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

Side drawer built on the native <dialog> element (showModal()/close()) for a free focus trap, Escape-as-cancel, top-layer stacking, and background inertness — only the entrance/exit motion and the counterweight are hand-rolled. The drawer is a right-anchored panel (position:fixed, full height, width min(24rem,88vw)) translated via a CSS custom property (--sash-tx, written every frame by a direct-DOM rAF loop, zero React state on the hot path) from 0 (open) to its own measured width (closed, off-screen). Its leading (left) edge carries an aria-hidden vertical rail: a 200px 1px --border track, a fixed tick mark at the track's midpoint (the balance point), and a 24px --foreground pill (--sash-wy custom property) that during a drag moves at -0.6x the drawer's pixel delta and in the opposite sense — drag the drawer toward closed and the weight rises toward the tick; drag it open and the weight falls past it. On pointerup, release velocity is computed from the mean pointer delta over the last 80ms of samples: above 500px/s the fling direction decides outright, otherwise whichever side of the tick the weight is currently sitting on decides — above the tick (hasn't reached it) springs to fully closed and calls dialog.close(), at or past the tick (bottomed toward open) springs to fully open, both via a semi-implicit-Euler spring (stiffness 260, damping 24 opening / 30 closing, mass 1) seeded with the actual release velocity so the settle reads as one continuous motion rather than a snap then a separate glide. The trigger button and Escape/backdrop/close-button paths run the identical spring (seeded at zero velocity) — dragging is an optional, purely pointer-driven alternative to the same open/close contract, not a separate code path. Scrim opacity (::backdrop, which inherits custom properties from its originating dialog element) is slaved every frame to (1 - tx/width), clamped to a max 0.55 alpha, token-derived per theme via color-mix. A single engine.request(target, velocity) function is the sole entry point for every open/close intent; two calls already resolving toward the same boolean collapse into one (only the first supplies a velocity, a later echo just refreshes the forced-settle deadline), so a React effect that reflects a controlled `open` prop can never stomp a drag-release's velocity back to zero. role=dialog (native, aria-modal, aria-labelledby the visible heading) with a real close button, Escape closes and returns focus to the trigger via native dialog behavior, and the trigger fully opens/closes via keyboard (aria-expanded, aria-haspopup=dialog) with no drag required. A visually-hidden aria-live=polite status region announces 'opened'/'closed' since the weight's position carries no non-visual signal. Reduced motion drops both springs for a 150ms CSS opacity fade on the panel itself and disables weight travel entirely — drag is ignored and the weight appears already parked at whichever end matches the resting state. Unlike pricing-scale (which balances two DOM subjects against each other on a single shared beam) or Vaul/shadcn Sheet (an invisible velocity threshold resolved internally), this externalizes the drawer's own snap decision as a second visible moving part whose rest position is the state — weight down always means held open, readable before anyone touches it. Demo is a catalog page with a search toolbar, a Filters trigger showing an active-count badge, and the drawer holding category checkboxes, a sort select, and Apply/Reset actions over a product grid.

Props

PropTypeDefaultDescription
open?boolean—controlled open state; omit for uncontrolled
defaultOpen?booleanfalseuncontrolled initial open state. Default false.
onOpenChange?(open: boolean) => void—called with the new open state after the trigger or a dismissal
trigger?ReactNode"Open panel"label rendered on the trigger button
titlestring—drawer heading
description?ReactNode—supporting copy under the title
children?ReactNode—drawer body content
className?string—extra classes merged onto the rendered root element