{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drawer-counterweight",
  "title": "Drawer Counterweight",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/drawer-counterweight/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState, type ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// SashWeight — a side drawer hung on a counterweight, sash-window style. The\n// drawer's leading edge carries a thin vertical track; a 24px weight pill\n// rides it, traveling opposite the drawer at a -0.6x ratio while the drawer\n// is dragged, plus a tick marking the balance point. Release before the tick\n// (weight still above it) and a spring pulls the drawer shut; release past it\n// (weight bottoms out) and the drawer holds itself open — the weight's\n// resting position (down = open, up = closed) is the state indicator, true\n// even when nobody is mid-drag. Built on the native <dialog> element for\n// showModal()'s free focus trap, Escape-as-cancel, top-layer stacking and\n// background inertness; only the entrance/exit motion and the counterweight\n// are hand-rolled. All motion (drawer translateX, weight translateY, scrim\n// opacity) is driven by a direct-DOM rAF loop writing CSS custom properties —\n// zero React state on the hot path. Two spring profiles (opening: k=260\n// zeta≈0.74 / closing: k=260 zeta≈0.93, mass 1) resolve the drawer+weight\n// together toward whichever side the release lands on, seeded with the\n// actual release velocity so the catch reads as one continuous motion, not a\n// snap-then-glide. Reduced motion drops both springs for a 150ms opacity fade\n// and disables weight travel — it appears already parked at the resting spot.\n// ---------------------------------------------------------------------------\n\nconst WEIGHT_SIZE = 24; // px — the pill\nconst TRACK_LEN = 200; // px — visual track length\nconst TRAVEL = TRACK_LEN - WEIGHT_SIZE; // px of usable weight travel\nconst BALANCE_FRAC = 0.5; // the tick's position along the travel\nconst BALANCE_Y = BALANCE_FRAC * TRAVEL;\nconst RATIO = 0.6; // weight moves at -0.6x the drawer's drag delta\n\nconst OPEN_K = 260;\nconst OPEN_C = 24;\nconst CLOSE_K = 260;\nconst CLOSE_C = 30;\nconst FLING_V = 500; // px/s — release speed past this overrides tick position\nconst V_WINDOW = 80; // ms of pointer samples averaged into release velocity\nconst SETTLE_MS = 1000; // forced-settle deadline\nconst POS_EPS = 0.4;\nconst VEL_EPS = 6;\nconst FADE_MS = 150; // reduced-motion opacity fade\n\nfunction clamp(v: number, lo: number, hi: number): number {\n  return v < lo ? lo : v > hi ? hi : v;\n}\n\nexport interface SashWeightProps {\n  /** controlled open state; omit for uncontrolled */\n  open?: boolean;\n  /** uncontrolled initial open state. Default false. */\n  defaultOpen?: boolean;\n  /** called with the new open state after the trigger or a dismissal */\n  onOpenChange?: (open: boolean) => void;\n  /** label rendered on the trigger button */\n  trigger?: ReactNode;\n  /** drawer heading */\n  title: string;\n  /** supporting copy under the title */\n  description?: ReactNode;\n  /** drawer body content */\n  children?: ReactNode;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function SashWeight({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  trigger = \"Open panel\",\n  title,\n  description,\n  children,\n  className = \"\",\n}: SashWeightProps) {\n  const isControlled = open !== undefined;\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const openState = isControlled ? (open as boolean) : internalOpen;\n\n  const dialogRef = useRef<HTMLDialogElement>(null);\n  const railRef = useRef<HTMLDivElement>(null);\n  const weightRef = useRef<HTMLSpanElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const liveRef = useRef<HTMLParagraphElement>(null);\n  const engineRef = useRef<{ request: (target: boolean, v?: number) => void } | null>(null);\n\n  const titleId = useId();\n  const descId = useId();\n  const dialogId = useId();\n\n  const notify = (next: boolean) => {\n    if (!isControlled) setInternalOpen(next);\n    onOpenChange?.(next);\n  };\n  // the setup effect below mounts once; its pointer/keyboard handlers must\n  // still call whatever `onOpenChange` the *latest* render passed in, not\n  // the one captured when the effect first ran — mirrored every render.\n  const notifyRef = useRef(notify);\n  notifyRef.current = notify;\n\n  useEffect(() => {\n    const dlg = dialogRef.current;\n    const rail = railRef.current;\n    const weight = weightRef.current;\n    const live = liveRef.current;\n    if (!dlg || !rail || !weight || !live) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let reduced = mq.matches;\n    const onMq = () => {\n      reduced = mq.matches;\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    // -- hot-path state: locals only, never React state ----------------------\n    let W = 0; // measured drawer width, px\n    let sized = false;\n    let everOpened = false; // has the dialog been shown at least once yet?\n    let tx = 0; // drawer offset from open, 0 = open, W = closed (off-screen)\n    let wY = 0; // weight offset within its travel, 0 = top/closed, TRAVEL = bottom/open\n    let vTx = 0;\n    let vWy = 0;\n    let mode: 0 | 1 | 2 = 0; // idle | dragging | spring-resolving\n    let targetOpen = openState;\n    let pendingTarget: boolean | null = null;\n    let deadline = 0;\n    let raf = 0;\n    let last = 0;\n    let fadeTimer = 0;\n\n    // drag bookkeeping\n    let pointerId = -1;\n    let startPointerX = 0;\n    let pointerX = 0;\n    let startTx = 0;\n    let startWy = 0;\n    let samples: { t: number; x: number }[] = [];\n\n    const render = () => {\n      dlg.style.setProperty(\"--sash-tx\", `${tx.toFixed(2)}px`);\n      weight.style.setProperty(\"--sash-wy\", `${wY.toFixed(2)}px`);\n      const progress = W > 0 ? clamp(1 - tx / W, 0, 1) : 0;\n      dlg.style.setProperty(\"--sash-scrim-op\", (progress * 0.55).toFixed(3));\n    };\n\n    const announce = (isOpen: boolean) => {\n      live.textContent = isOpen ? `${title} opened` : `${title} closed`;\n    };\n\n    const resize = () => {\n      const rect = dlg.getBoundingClientRect();\n      if (rect.width < 40) {\n        sized = false;\n        return;\n      }\n      const prevW = W;\n      W = rect.width;\n      sized = true;\n      // re-anchor to whichever end is currently settled when the viewport\n      // itself resizes — but never on the very first measurement (that one\n      // is the bootstrap for a fresh spring-open, and everOpened being false\n      // there keeps this from snapping tx to its target before the spring in\n      // request() ever gets a displacement to resolve).\n      if (mode === 0 && everOpened && W !== prevW) {\n        tx = targetOpen ? 0 : W;\n        render();\n      }\n      if (pendingTarget !== null) {\n        const t = pendingTarget;\n        pendingTarget = null;\n        request(t, 0);\n      }\n    };\n\n    const fadeTo = (target: boolean) => {\n      window.clearTimeout(fadeTimer);\n      if (target) {\n        dlg.style.transition = \"none\";\n        dlg.style.opacity = \"0\";\n        requestAnimationFrame(() => {\n          dlg.style.transition = `opacity ${FADE_MS}ms ease-out`;\n          dlg.style.opacity = \"1\";\n        });\n      } else {\n        dlg.style.transition = `opacity ${FADE_MS}ms ease-out`;\n        dlg.style.opacity = \"0\";\n        fadeTimer = window.setTimeout(() => {\n          if (dlg.open) dlg.close();\n          dlg.style.opacity = \"\";\n          dlg.style.transition = \"\";\n        }, FADE_MS + 20);\n      }\n    };\n\n    const wake = () => {\n      if (!raf && sized && mode !== 0 && !document.hidden) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const finishSettle = () => {\n      mode = 0;\n      if (!targetOpen && dlg.open) dlg.close();\n      announce(targetOpen);\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (document.hidden || mode === 0) {\n        last = 0;\n        return;\n      }\n      const dt = last ? Math.min(0.032, (now - last) / 1000) : 1 / 60;\n      last = now;\n\n      if (mode === 1) {\n        const dx = pointerX - startPointerX;\n        tx = clamp(startTx + dx, 0, W);\n        wY = clamp(startWy - RATIO * dx, 0, TRAVEL);\n      } else {\n        const k = targetOpen ? OPEN_K : CLOSE_K;\n        const c = targetOpen ? OPEN_C : CLOSE_C;\n        const tTx = targetOpen ? 0 : W;\n        const tWy = targetOpen ? TRAVEL : 0;\n        vTx += (-k * (tx - tTx) - c * vTx) * dt;\n        tx += vTx * dt;\n        vWy += (-k * (wY - tWy) - c * vWy) * dt;\n        wY += vWy * dt;\n\n        const settled =\n          Math.abs(tx - tTx) < POS_EPS &&\n          Math.abs(vTx) < VEL_EPS &&\n          Math.abs(wY - tWy) < POS_EPS &&\n          Math.abs(vWy) < VEL_EPS;\n        if (settled || now >= deadline) {\n          tx = tTx;\n          wY = tWy;\n          vTx = vWy = 0;\n          render();\n          finishSettle();\n          return;\n        }\n      }\n      render();\n      raf = requestAnimationFrame(loop);\n    };\n\n    // engine.request — single authoritative entry point for every open/close\n    // intent (trigger, close button, backdrop, Escape, drag release). Two\n    // calls targeting the same in-flight destination collapse into one: only\n    // the first supplies a velocity, the second just refreshes the deadline —\n    // so a React-effect echo of a decision already dispatched imperatively\n    // from a pointerup handler can never stomp its velocity back to zero.\n    const request = (target: boolean, v = 0) => {\n      if (mode === 2 && targetOpen === target) {\n        deadline = performance.now() + SETTLE_MS;\n        return;\n      }\n      if (target) {\n        if (!dlg.open) dlg.showModal();\n        if (!sized) resize();\n        if (!sized) {\n          pendingTarget = target;\n          return;\n        }\n        if (!everOpened) {\n          // first-ever open: seed the closed origin explicitly so the spring\n          // below actually has a displacement to resolve, instead of\n          // discovering tx already equal to its own target.\n          tx = W;\n          wY = 0;\n          everOpened = true;\n        }\n      } else if (!dlg.open) {\n        mode = 0;\n        return; // already fully closed, nothing to animate\n      }\n\n      if (reduced) {\n        tx = target ? 0 : W;\n        wY = target ? TRAVEL : 0;\n        vTx = vWy = 0;\n        mode = 0;\n        render();\n        fadeTo(target);\n        announce(target);\n        return;\n      }\n\n      targetOpen = target;\n      vTx = v;\n      vWy = -RATIO * v;\n      mode = 2;\n      deadline = performance.now() + SETTLE_MS;\n      wake();\n    };\n\n    engineRef.current = { request };\n\n    const onRailDown = (e: PointerEvent) => {\n      if (reduced || !dlg.open || mode === 1 || e.button !== 0) return;\n      pointerId = e.pointerId;\n      startPointerX = e.clientX;\n      pointerX = e.clientX;\n      startTx = tx;\n      startWy = wY;\n      samples = [{ t: e.timeStamp, x: e.clientX }];\n      mode = 1;\n      rail.style.cursor = \"grabbing\";\n      try {\n        rail.setPointerCapture(e.pointerId);\n      } catch {\n        /* pointer already gone */\n      }\n      wake();\n    };\n\n    const onRailMove = (e: PointerEvent) => {\n      if (mode !== 1 || e.pointerId !== pointerId) return;\n      pointerX = e.clientX;\n      samples.push({ t: e.timeStamp, x: e.clientX });\n      while (samples.length > 1 && e.timeStamp - (samples[0]?.t ?? 0) > V_WINDOW) {\n        samples.shift();\n      }\n    };\n\n    const releaseDrag = (e: PointerEvent) => {\n      if (e.pointerId !== pointerId) return;\n      pointerId = -1;\n      rail.style.cursor = \"\";\n      if (mode !== 1) return;\n      const cutoff = e.timeStamp - V_WINDOW;\n      let i0 = 0;\n      while (i0 < samples.length - 1 && (samples[i0]?.t ?? 0) < cutoff) i0++;\n      const a = samples[i0];\n      const b = samples[samples.length - 1];\n      let vRelease = 0;\n      if (a && b && b.t - a.t > 8) vRelease = ((b.x - a.x) / (b.t - a.t)) * 1000;\n      const decision =\n        Math.abs(vRelease) > FLING_V ? vRelease < 0 : wY > BALANCE_Y;\n      request(decision, vRelease);\n      notify(decision);\n    };\n\n    const onBackdropDown = (e: PointerEvent) => {\n      // backdrop clicks target the dialog itself (::backdrop isn't a real\n      // element); a click landing inside the panel targets a descendant.\n      if (e.target === dlg) {\n        request(false, 0);\n        notify(false);\n      }\n    };\n    const onCancel = (e: Event) => {\n      e.preventDefault();\n      request(false, 0);\n      notify(false);\n    };\n    const onNativeClose = () => {\n      // covers any close path that bypassed request() (shouldn't happen, but\n      // keeps state honest if something calls dlg.close() directly)\n      if (mode !== 0) mode = 0;\n    };\n    dlg.addEventListener(\"pointerdown\", onBackdropDown);\n    dlg.addEventListener(\"cancel\", onCancel);\n    dlg.addEventListener(\"close\", onNativeClose);\n    rail.addEventListener(\"pointerdown\", onRailDown);\n    rail.addEventListener(\"pointermove\", onRailMove);\n    rail.addEventListener(\"pointerup\", releaseDrag);\n    rail.addEventListener(\"pointercancel\", releaseDrag);\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(dlg);\n    // Deliberately no IntersectionObserver here: this dialog is\n    // position:fixed (viewport-relative, unaffected by page scroll), and the\n    // *only* thing that ever changes its on-/off-screen intersection is its\n    // own open/close transform — so gating the rAF loop on \"currently\n    // intersecting\" would gate the animation on its own output. A close\n    // settles with the panel translated fully off-screen (non-intersecting)\n    // in the same frame the spring reaches its target; an IO callback firing\n    // on that same closed frame would latch a `visible=false` that nothing\n    // could ever clear (the panel can only become intersecting again by\n    // running the very animation the flag blocks), silently swallowing or\n    // cutting off the next open/close request — which reads as the panel\n    // popping partway open/closed and resetting, over and over. Tab\n    // backgrounding is still covered, correctly, by `document.hidden` below.\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    // no direct kickoff here — the `[openState]` effect below fires on mount\n    // too (openState starts defined), and engineRef.current is already wired\n    // by the time it runs in the same commit. One call site, no echo to guard.\n\n    return () => {\n      cancelAnimationFrame(raf);\n      window.clearTimeout(fadeTimer);\n      mq.removeEventListener(\"change\", onMq);\n      dlg.removeEventListener(\"pointerdown\", onBackdropDown);\n      dlg.removeEventListener(\"cancel\", onCancel);\n      dlg.removeEventListener(\"close\", onNativeClose);\n      rail.removeEventListener(\"pointerdown\", onRailDown);\n      rail.removeEventListener(\"pointermove\", onRailMove);\n      rail.removeEventListener(\"pointerup\", releaseDrag);\n      rail.removeEventListener(\"pointercancel\", releaseDrag);\n      ro.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      engineRef.current = null;\n    };\n    // title feeds the aria-live announcement text but shouldn't retrigger the\n    // whole engine setup — read fresh via the closure is fine since it only\n    // changes if the caller renames the panel, an edge case not worth a\n    // teardown/rebuild of the pointer + observer wiring.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // reacts to externally-controlled `open` prop changes (or the initial\n  // uncontrolled value). Internal handlers already drive the engine\n  // imperatively and in the same tick, so this mostly matters for a parent\n  // flipping `open` without going through this component's own controls —\n  // request()'s same-target guard makes a redundant echo harmless.\n  useEffect(() => {\n    engineRef.current?.request(openState, 0);\n  }, [openState]);\n\n  const handleTriggerClick = () => {\n    engineRef.current?.request(true, 0);\n    notify(true);\n  };\n  const handleClose = () => {\n    engineRef.current?.request(false, 0);\n    notify(false);\n  };\n\n  return (\n    <>\n      <dialog\n        ref={dialogRef}\n        id={dialogId}\n        aria-modal=\"true\"\n        aria-labelledby={titleId}\n        aria-describedby={description ? descId : undefined}\n        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}`}\n      >\n        <div className=\"flex h-full\">\n          {/* leading-edge sash rail: decorative counterweight track, and the\n              optional drag handle. aria-hidden — the trigger button already\n              exposes open/closed via aria-expanded, and a visible weight\n              position conveys nothing to assistive tech. */}\n          <div\n            ref={railRef}\n            aria-hidden\n            className=\"relative flex w-8 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border select-none\"\n          >\n            <div\n              className=\"relative bg-border\"\n              style={{ height: TRACK_LEN, width: 1 }}\n            >\n              <span\n                className=\"absolute left-1/2 h-px w-3 -translate-x-1/2 bg-border\"\n                style={{ top: BALANCE_Y }}\n              />\n              <span\n                ref={weightRef}\n                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\"\n                style={{ transform: \"translateY(var(--sash-wy, 0px))\" }}\n              />\n            </div>\n          </div>\n\n          {/* showModal() focuses the first focusable descendant unless something\n              claims it — here that's the close button, which paints a focus\n              ring at rest (this demo rests open). Same pattern as\n              dialog-emerge: take the focus on the panel itself, so screen\n              readers announce the title and Tab starts at the top without\n              preselecting a control. */}\n          <div autoFocus tabIndex={-1} className=\"flex min-w-0 flex-1 flex-col outline-none\">\n            <header className=\"flex items-start justify-between gap-4 border-b border-border px-5 py-4\">\n              <div className=\"min-w-0\">\n                <h2 id={titleId} className=\"text-sm font-semibold text-foreground\">\n                  {title}\n                </h2>\n                {description && (\n                  <p id={descId} className=\"mt-1 text-xs leading-relaxed text-ns-muted\">\n                    {description}\n                  </p>\n                )}\n              </div>\n              <button\n                type=\"button\"\n                onClick={handleClose}\n                aria-label=\"Close\"\n                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\"\n              >\n                <svg aria-hidden viewBox=\"0 0 16 16\" width=\"14\" height=\"14\" fill=\"none\">\n                  <path\n                    d=\"M4 4l8 8M12 4l-8 8\"\n                    stroke=\"currentColor\"\n                    strokeWidth=\"1.5\"\n                    strokeLinecap=\"round\"\n                  />\n                </svg>\n              </button>\n            </header>\n            <div className=\"min-h-0 flex-1 overflow-y-auto px-5 py-4\">{children}</div>\n          </div>\n        </div>\n\n        <p ref={liveRef} role=\"status\" aria-live=\"polite\" className=\"sr-only\" />\n\n        <style>{`\n          .ns-sash {\n            position: fixed;\n            inset: 0 0 0 auto;\n            transform: translateX(var(--sash-tx, 100%));\n          }\n          .ns-sash::backdrop {\n            background: color-mix(in srgb, var(--foreground) 22%, transparent);\n            opacity: var(--sash-scrim-op, 0);\n          }\n          :where(.dark) .ns-sash::backdrop {\n            background: color-mix(in srgb, var(--background) 66%, transparent);\n          }\n          @media (prefers-reduced-motion: reduce) {\n            .ns-sash { transition: none; }\n          }\n        `}</style>\n      </dialog>\n\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"dialog\"\n        aria-expanded={openState}\n        aria-controls={dialogId}\n        onClick={handleTriggerClick}\n        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\"\n      >\n        {trigger}\n      </button>\n    </>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/drawer-counterweight.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "drawer",
      "sheet",
      "dialog",
      "physics",
      "spring",
      "drag",
      "panel",
      "nav"
    ],
    "instruction": "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."
  },
  "type": "registry:ui"
}