# Drawer (Moonarc)

A bottom sheet that slides up over a scrim and can be dragged closed: it follows the finger, and on release springs back or leaves depending on distance and velocity, interruptible mid-flight. A native <dialog> does the top layer, focus and Escape; the script is only the gesture.

- Import: `import Drawer from '@moonarc/core/Drawer'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/drawer.json`
- Tier C · category ui · trigger click, pointer
- Readout: `<Drawer threshold={0.35}>`
- Browser support: newly (Chrome 135 · Firefox 144 · Safari 26.2); elsewhere: without invoker commands the trigger needs polyfill; without closedby (Safari) a tap on the scrim does nothing, and the drag and Escape still close the sheet
- Measured cost: 933 B raw JS · 500 B gzip · + runtime (with dependencies 2.6 kB raw; CSS 5.7 kB raw)
- Page: https://moonarc.dev/components/drawer/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | none | Dialog id; the trigger points at it. |
| `label` | `string` | `'Open'` | Trigger label. Use the trigger slot for custom markup (give it command="show-modal" commandfor={id}). |
| `height` | `string` | `'auto'` | Sheet height, up to 90 dvh. |
| `threshold` | `number` | `0.35` | Fraction of the height that must be dragged to dismiss; a fast flick dismisses regardless. |
| `polyfill` | `boolean` | `false` | Inline a click handler (403 B raw) for browsers without invoker commands. Document-level: one per page is enough. |

## Usage

```astro
<Drawer id="filters" label="Filters" height="60dvh">
  <h2>Filters</h2>
  <p>Drag down to dismiss.</p>
</Drawer>
```

## Reduced motion

Opens and closes with a 150 ms linear slide; dragging still works because it is direct manipulation.

## With ClientRouter

Bound through the shared runtime; pointer listeners are aborted before the swap and a sheet open during navigation closes with the page.

## Craft

- While dragging, transition: none, so the sheet stays under the finger. On release the transition returns, so the spring-back is the drawer curve.
- Two ways out: 35% of the height, or 0.6 px/ms. A short fast flick should close; a long slow drag that stops short should not.
- The body scrolls; the sheet only takes the gesture when the body is at its top, so scrolling content never accidentally dismisses.
- A press becomes a drag after 6 px of travel, and only then does the sheet capture the pointer: until then it is a click, so a button, a link or a checkbox inside the sheet works with a mouse, a pen or a finger.
- Exit is ease-in at the fast duration: things leaving accelerate away.

## Replaces

- Vaul
- Drawer (shadcn)
- react-spring-bottom-sheet

## Source

```astro
---
/**
 * Drawer — a bottom sheet you can drag closed.
 *
 * A native <dialog> for the top layer, focus and Escape; the invoker
 * command opens it and @starting-style / allow-discrete animate it. The
 * script adds one thing CSS cannot: the sheet follows the finger, and on
 * release either springs back (preset curve) or leaves — by distance or
 * by velocity — with the transition interrupted mid-flight if you grab it.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'dialog'> {
  /** Dialog id; the trigger points at it. */
  id: string;
  /** Trigger label; or use the `trigger` slot. */
  label?: string;
  /** Sheet height, any CSS length. */
  height?: string;
  /** Fraction of the height that must be dragged to dismiss. */
  threshold?: number;
  /** Add the inline click handler for browsers without invoker commands. */
  polyfill?: boolean;
}

const { id, label = 'Open', height = 'auto', threshold = 0.35, polyfill = false, class: className, style, ...rest } = Astro.props;
const inline = [`--ma-drawer-h:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const triggerAttrs = { command: 'show-modal', commandfor: id };
const dialogAttrs = { closedby: 'any' as const };
---

{Astro.slots.has('trigger') ? <slot name="trigger" /> : <button type="button" class="ma-drawer__trigger" {...triggerAttrs}>{label}</button>}
<dialog id={id} class:list={['ma-drawer', className]} data-ma-drawer data-threshold={threshold} style={inline} {...dialogAttrs} {...rest}>
  <div class="ma-drawer__sheet">
    <div class="ma-drawer__handle" aria-hidden="true"></div>
    <div class="ma-drawer__body">
      <slot />
    </div>
  </div>
</dialog>
{polyfill && <script is:inline>window.__maCmd||(window.__maCmd=1,document.addEventListener('click',function(e){if('command'in HTMLButtonElement.prototype||e.defaultPrevented)return;var b=e.target.closest('[commandfor]');if(!b)return;var d=document.getElementById(b.getAttribute('commandfor'));if(!d)return;var c=b.getAttribute('command');c==='show-modal'?d.showModal():c==='close'?d.close():c==='toggle-popover'?d.togglePopover():0}))</script>}

<style is:global>
  @layer components {
    :where(.ma-drawer) {
      inset: auto 0 0;
      margin: 0 auto;
      width: 100%;
      max-width: 40rem;
      max-height: 90dvh;
      height: var(--ma-drawer-h, auto);
      border: 0;
      padding: 0;
      background: transparent;
      color: inherit;
      overflow: visible;
      /* --ma-drag: live pointer offset written by the script */
      translate: 0 var(--ma-drag, 0px);
      transition:
        translate var(--ma-duration-fast) var(--ma-ease-in),
        display var(--ma-duration-fast) allow-discrete,
        overlay var(--ma-duration-fast) allow-discrete;
    }
    :where(.ma-drawer:not([open])) {
      translate: 0 100%;
    }
    :where(.ma-drawer[open]) {
      transition-duration: var(--ma-duration);
      transition-timing-function: var(--ma-ease-drawer);
    }
    :where(.ma-drawer[data-dragging]) {
      transition: none;
    }
    @starting-style {
      :where(.ma-drawer[open]) {
        translate: 0 100%;
      }
    }
    :where(.ma-drawer)::backdrop {
      background: var(--ma-scrim);
      opacity: 0;
      transition:
        opacity var(--ma-duration-fast) var(--ma-ease-out),
        display var(--ma-duration-fast) allow-discrete,
        overlay var(--ma-duration-fast) allow-discrete;
    }
    :where(.ma-drawer[open])::backdrop {
      opacity: 1;
      transition-duration: var(--ma-duration);
    }
    @starting-style {
      :where(.ma-drawer[open])::backdrop {
        opacity: 0;
      }
    }
    :where(.ma-drawer__sheet) {
      display: grid;
      grid-template-rows: auto 1fr;
      max-height: 90dvh;
      border-radius: 1.25rem 1.25rem 0 0;
      background: var(--ma-panel);
      border: 1px solid var(--ma-edge);
      border-bottom: 0;
      box-shadow: 0 -24px 64px -32px rgb(0 0 0 / 0.5);
      touch-action: none;
    }
    :where(.ma-drawer__handle) {
      width: 2.5rem;
      height: 0.3rem;
      margin: 0.75rem auto 0.25rem;
      border-radius: 999px;
      background: var(--ma-edge);
      cursor: grab;
    }
    :where(.ma-drawer__body) {
      padding: 1rem 1.5rem 1.5rem;
      overflow: auto;
      touch-action: pan-y;
    }
    :where(.ma-drawer__trigger) {
      cursor: pointer;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-drawer),
      :where(.ma-drawer[open]),
      :where(.ma-drawer)::backdrop {
        transition-duration: var(--ma-duration-fast);
        transition-timing-function: linear;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLDialogElement>('[data-ma-drawer]', (dialog, { signal }) => {
    const sheet = dialog.querySelector<HTMLElement>('.ma-drawer__sheet');
    const body = dialog.querySelector<HTMLElement>('.ma-drawer__body');
    if (!sheet || !body) return;
    const threshold = Number(dialog.dataset.threshold ?? 0.35);
    let startY = 0;
    let lastY = 0;
    let lastT = 0;
    let v = 0;
    // 0 idle, 1 pressed, 2 dragging
    let state = 0;

    sheet.addEventListener(
      'pointerdown',
      (e) => {
        // a scrolled body scrolls; only a body at the top hands the gesture to the sheet
        if (body.contains(e.target as Node) && body.scrollTop > 0) return;
        state = 1;
        startY = lastY = e.clientY;
        lastT = e.timeStamp;
        v = 0;
      },
      { signal },
    );
    sheet.addEventListener(
      'pointermove',
      (e) => {
        if (!state) return;
        const dy = Math.max(0, e.clientY - startY);
        // a press is a click until it travels 6 px: only then does the sheet take the pointer, so a button, a link or a
        // checkbox inside keeps its click (captured on pointerdown, the click went to the sheet)
        if (state < 2) {
          if (dy < 6) return;
          state = 2;
          dialog.dataset.dragging = '';
          sheet.setPointerCapture(e.pointerId);
        }
        const dt = e.timeStamp - lastT || 1;
        v = (e.clientY - lastY) / dt; // px per ms, positive = downward
        lastY = e.clientY;
        lastT = e.timeStamp;
        dialog.style.setProperty('--ma-drag', `${dy}px`);
      },
      { signal },
    );
    const release = (e: PointerEvent) => {
      const was = state;
      state = 0;
      if (was < 2) return;
      delete dialog.dataset.dragging;
      const dy = Math.max(0, e.clientY - startY);
      const h = dialog.getBoundingClientRect().height || 1;
      dialog.style.removeProperty('--ma-drag');
      if (dy / h > threshold || v > 0.6) dialog.close();
    };
    // on the dialog: a press let go over the backdrop before it became a drag ends there, uncaptured
    dialog.addEventListener('pointerup', release, { signal });
    dialog.addEventListener('pointercancel', release, { signal });
  });
</script>

```
