# Drawer

Bottom sheet you can fling closed. Drag past a threshold or flick down, and it rubber-bands at the top.

**Category:** Overlays  
**Exports:** `Drawer`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/drawer.json
```
The file lands at `components/ui/drawer.tsx`; import from `@/components/ui/drawer`.

## API — Drawer
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | `boolean` | — |  |
| `onOpenChange` | `(open: boolean) => void` | — |  |
| `title` | `ReactNode` | — |  |
| `children` | `ReactNode` | — |  |

## Example — Drawer

```tsx
import * as React from "react";
import { Drawer } from "@/components/ui/drawer";
import { Button } from "@/components/ui/button";
import { RadioGroup } from "@/components/ui/radio-group";

export default function DrawerDemo({
  title = "Delivery speed",
  description = "Drag the sheet down or flick it to close.",
}: {
  title?: string;
  description?: string;
}) {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Open drawer
      </Button>
      <Drawer open={open} onOpenChange={setOpen} title={title} description={description}>
        <RadioGroup
          aria-label="Delivery speed"
          variant="cards"
          defaultValue="standard"
          options={[
            { value: "standard", label: "Standard", description: "3–5 days · Free" },
            { value: "express", label: "Express", description: "1–2 days · $9" },
            { value: "same-day", label: "Same day", description: "Before 9 pm · $19" },
          ]}
        />
        <Button className="mt-5 w-full" size="lg" onClick={() => setOpen(false)}>
          Confirm delivery
        </Button>
      </Drawer>
    </>
  );
}
```

## Source — components/ui/drawer.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type DrawerProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title: React.ReactNode;
  description?: React.ReactNode;
  className?: string;
  children?: React.ReactNode;
};

/** Native <dialog> gives us the top layer, focus trapping, inert background and Escape. */
function useModal(open: boolean, onOpenChange: (open: boolean) => void) {
  const ref = React.useRef<HTMLDialogElement>(null);
  const [mounted, setMounted] = React.useState(open);
  if (open && !mounted) setMounted(true);
  React.useEffect(() => {
    const d = ref.current;
    if (open && d && !d.open) d.showModal();
  }, [open, mounted]);
  const onExitComplete = () => {
    ref.current?.close();
    setMounted(false);
  };
  const dialogProps = {
    ref,
    onCancel: (e: React.SyntheticEvent) => {
      e.preventDefault();
      onOpenChange(false);
    },
    className:
      "fixed inset-0 m-0 h-dvh max-h-none w-full max-w-none overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-transparent",
  };
  return { mounted, dialogProps, onExitComplete };
}

export function Drawer({ open, onOpenChange, title, description, className, children }: DrawerProps) {
  const id = React.useId();
  const reduce = useReducedMotion();
  const { mounted, dialogProps, onExitComplete } = useModal(open, onOpenChange);
  if (!mounted) return null;

  return (
    <dialog {...dialogProps} aria-labelledby={`${id}-title`}>
      <AnimatePresence onExitComplete={onExitComplete}>
        {open && (
          <motion.div key="layer" className="absolute inset-0">
            <motion.div
              className="absolute inset-0 bg-background/60 backdrop-blur-sm"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              onClick={() => onOpenChange(false)}
            />
            <motion.div
              className={cn(
                "absolute inset-x-0 bottom-0 mx-auto max-h-[85dvh] w-full max-w-lg touch-none rounded-t-3xl border border-b-0 bg-popover px-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] pt-3 text-popover-foreground shadow-[0_-20px_60px_-20px_rgb(0_0_0/0.5)]",
                className,
              )}
              initial={{ y: reduce ? 0 : "100%", opacity: reduce ? 0 : 1 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: reduce ? 0 : "100%", opacity: reduce ? 0 : 1, transition: { type: "spring", stiffness: 400, damping: 40 } }}
              transition={{ type: "spring", stiffness: 380, damping: 36 }}
              drag={reduce ? false : "y"}
              dragConstraints={{ top: 0, bottom: 0 }}
              dragElastic={{ top: 0.06, bottom: 0.9 }}
              onDragEnd={(_, info) => {
                if (info.offset.y > 120 || info.velocity.y > 550) onOpenChange(false);
              }}
            >
              <div aria-hidden className="mx-auto mb-5 h-1.5 w-10 cursor-grab rounded-full bg-muted-foreground/40 active:cursor-grabbing" />
              <h2 id={`${id}-title`} className="text-lg font-semibold tracking-tight">
                {title}
              </h2>
              {description && <p className="mt-1 text-sm leading-6 text-muted-foreground">{description}</p>}
              <div className="mt-5 max-h-[60dvh] touch-pan-y overflow-y-auto">{children}</div>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </dialog>
  );
}
```