Skip to content

Components / UI

Drawer

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.

Live demo

drawer

Drag it down.

Follows the finger; on release, distance or velocity decides. Grab it mid-flight and it obeys.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

933 B raw

500 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.6 kB raw.

CSS 5.7 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 135 · Firefox 144 · Safari 26.2; needs command, starting-style, allow-discrete, dialog. 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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Drawer from '@moonarc/core/Drawer';
---
<Drawer id="filters" label="Filters" height="60dvh">
  <h2>Filters</h2>
  <p>Drag down to dismiss.</p>
</Drawer>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/drawer.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

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

Props

PropTypeDefaultDescription
idstringnoneDialog id; the trigger points at it.
labelstring'Open'Trigger label. Use the trigger slot for custom markup (give it command="show-modal" commandfor={id}).
heightstring'auto'Sheet height, up to 90 dvh.
thresholdnumber0.35Fraction of the height that must be dragged to dismiss; a fast flick dismisses regardless.
polyfillbooleanfalseInline a click handler (403 B raw) for browsers without invoker commands. Document-level: one per page is enough.

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.

Why it is built this way

Replaces: Vaul · Drawer (shadcn) · react-spring-bottom-sheet. See the migration table.

dialog

Native, animated, zero script.

Escape, backdrop click and focus are the browser's. The scale and the blur are @starting-style and allow-discrete.

A native modal that scales in over a blurred backdrop and scales out again, closes on Escape, the close button or a backdrop click, and traps focus, all handled by the browser.

newly · Chrome 135 · Firefox 144 · Safari 26.2click

Raw bytes of JavaScript, from a production build, measured again for every change that can move them.

A popover that opens beside its trigger, flips when there is no room, closes on outside click or Escape, and animates in and out.

newly · Chrome 125 · Firefox 147 · Safari 26click

Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.

newly · Chrome 117 · Firefox 129 · Safari 17.5clickhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown