@MMDEV98
7 min read

Motion Drawer: A Headless Drawer for the Modern Web

Why I built motion-drawer — a headless, gesture-driven drawer with spring animations that plugs into Headless UI, Radix, React Aria, or any dialog you already have.

Mohammad Hossein Moradi
ReactMotionOpen Source

Every app eventually needs a bottom drawer. And every time, the same trade-off appears: the polished, gesture-driven feel of a native sheet — or the accessibility and composability of the headless dialog you already use. Existing drawer libraries tend to ship their own dialog, their own portal, their own focus trap. If your design system is built on Headless UI, Radix, or React Aria, that's a second source of truth you didn't ask for.

motion-drawer is my answer: a drawer that only does the drawer part — springs, gestures, snap behavior, scroll handling — and leaves the dialog semantics to whichever headless library you already trust.

The headless philosophy

A drawer is really two responsibilities glued together:

  1. Dialog semantics — portal, backdrop, focus trap, aria-modal, escape-to-close. Solved problems, solved well, by headless UI libraries.
  2. Sheet mechanics — the spring entrance, the drag-to-dismiss gesture, the velocity-aware release, the scroll area that hands off between content scrolling and sheet dragging.

motion-drawer implements only the second. It renders no portal and traps no focus. That's what makes it composable: it can live inside a Headless UI Dialog, a Radix Dialog.Content, or a plain div in a prototype.

Getting started

npm install motion-drawer
# or
pnpm add motion-drawer

The core API is four components:

import { Drawer, DrawerHeader, DrawerBody, DrawerActions } from "motion-drawer";

function MyComponent() {
  const [open, setOpen] = useState(false);

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerHeader>Header</DrawerHeader>
      <DrawerBody>Main content goes here (scroll area)</DrawerBody>
      <DrawerActions>Actions</DrawerActions>
    </Drawer>
  );
}

DrawerHeader and DrawerActions stay pinned while DrawerBody scrolls — the layout every real-world sheet ends up needing, from comment threads to checkout flows.

Composing with Headless UI

Here's the part I care most about. Because motion-drawer doesn't fight for control of the dialog, integration is just nesting:

"use client";

import { useState } from "react";
import { AnimatePresence } from "motion/react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { Drawer, DrawerHeader, DrawerBody, DrawerActions } from "motion-drawer";

export default function Comments() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <button onClick={() => setOpen(true)}>Open comments</button>
      <AnimatePresence>
        {open && (
          <Dialog open onClose={setOpen} className="fixed z-50">
            <DialogBackdrop />
            <DialogPanel>
              <Drawer open={open} onOpenChange={setOpen}>
                <DrawerHeader>Comments</DrawerHeader>
                <DrawerBody>{/* the scrollable thread */}</DrawerBody>
                <DrawerActions>{/* reply box */}</DrawerActions>
              </Drawer>
            </DialogPanel>
          </Dialog>
        )}
      </AnimatePresence>
    </>
  );
}

Headless UI owns focus management and aria wiring. motion-drawer owns the feel. Neither knows the other exists.

Why the animations feel right

The drawer is built on Motion, and everything is a spring — there are no fixed-duration tweens to fall out of sync with a gesture. When you flick the sheet, the release animation inherits your finger's velocity, which is the difference between a drawer that feels native and one that feels like a video playing back.

The scroll handoff matters just as much: dragging down on a scrolled-to-top DrawerBody starts dismissing the sheet, while the same gesture mid-list just scrolls. Getting that boundary right — without dead zones or accidental dismissals — was most of the work.

Try it

If you build something with it — or hit an edge case the gesture handling doesn't cover — issues and PRs are very welcome.