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.
npx shadcn add https://design.helpmarq.com /r/drawer-counterweight.jsonregistry/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;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** label rendered on the trigger button */
trigger?: ReactNode;
title: string;
description?: ReactNode;
/** drawer body content */
children?: ReactNode;
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-muted">
{description}
</p>
)}
</div>
<button
type="button"
onClick={handleClose}
aria-label="Close"
className="shrink-0 rounded-sm border border-border p-1.5 text-muted transition-colors duration-150 hover:border-foreground/25 hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-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-accent"
>
{trigger}
</button>
</>
);
}
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.