# Drawer

> Panel that slides in from a screen edge; the bottom sheet can be dragged down to close.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/drawer
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/drawer.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/drawer-demo.tsx"
"use client"

import * as React from "react"
import { ArrowDownToLine, PanelRight, ShoppingBag } from "lucide-react"

import { Drawer } from "@/components/drawer"

const button = "inline-flex h-9 items-center justify-center gap-2 rounded-lg px-4 text-sm font-medium transition-colors"

const cart = [
  { name: "Linen overshirt", meta: "Sand · M", price: 89 },
  { name: "Merino crew", meta: "Charcoal · L", price: 120 },
  { name: "Canvas tote", meta: "Natural", price: 35 },
]

export default function DrawerDemo() {
  const [side, setSide] = React.useState<"right" | "bottom" | null>(null)
  const total = cart.reduce((sum, item) => sum + item.price, 0)

  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex gap-3">
        <button type="button" onClick={() => setSide("right")} className={`${button} bg-primary text-primary-foreground hover:opacity-90`}>
          <PanelRight className="size-4" /> Open cart
        </button>
        <button type="button" onClick={() => setSide("bottom")} className={`${button} border bg-background hover:bg-accent`}>
          <ArrowDownToLine className="size-4" /> Bottom sheet
        </button>
      </div>
      <p className="text-xs text-muted-foreground">Drag the bottom sheet&apos;s handle down to dismiss it.</p>

      <Drawer
        open={side === "right"}
        onOpenChange={(open) => !open && setSide(null)}
        title={
          <span className="flex items-center gap-2">
            <ShoppingBag className="size-4" /> Your cart
          </span>
        }
        description={`${cart.length} items`}
        footer={
          <button type="button" onClick={() => setSide(null)} className={`${button} w-full bg-primary text-primary-foreground hover:opacity-90`}>
            Checkout · ${total}
          </button>
        }
      >
        <ul className="flex flex-col divide-y">
          {cart.map((item) => (
            <li key={item.name} className="flex items-center gap-3 py-3">
              <span className="size-12 rounded-lg bg-muted" />
              <div className="flex-1">
                <p className="text-sm font-medium">{item.name}</p>
                <p className="text-xs text-muted-foreground">{item.meta}</p>
              </div>
              <span className="text-sm tabular-nums">${item.price}</span>
            </li>
          ))}
        </ul>
      </Drawer>

      <Drawer
        open={side === "bottom"}
        onOpenChange={(open) => !open && setSide(null)}
        side="bottom"
        size={320}
        title="Share this project"
        description="Anyone with the link can view."
      >
        <div className="mx-auto flex max-w-md gap-2">
          <input
            readOnly
            value="https://atelier.dev/p/aurora-kit"
            aria-label="Share link"
            className="h-9 flex-1 rounded-lg border bg-muted/40 px-3 text-sm"
          />
          <button type="button" className={`${button} bg-primary text-primary-foreground`}>
            Copy
          </button>
        </div>
      </Drawer>
    </div>
  )
}
```

## Source

```tsx title="components/drawer.tsx"
"use client"

import * as React from "react"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

type Side = "right" | "left" | "bottom"

export interface DrawerProps extends Omit<React.ComponentProps<"dialog">, "open" | "title"> {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: React.ReactNode
  description?: React.ReactNode
  footer?: React.ReactNode
  side?: Side
  /** Width for side drawers, height for bottom ones, in px */
  size?: number
}

const placement: Record<Side, string> = {
  right: "ml-auto h-dvh max-h-none rounded-l-2xl border-l translate-x-full open:translate-x-0 starting:open:translate-x-full",
  left: "mr-auto h-dvh max-h-none rounded-r-2xl border-r -translate-x-full open:translate-x-0 starting:open:-translate-x-full",
  bottom: "mt-auto w-full max-w-none rounded-t-2xl border-t translate-y-full open:translate-y-0 starting:open:translate-y-full",
}

// A <dialog> pinned to one edge. Bottom drawers can also be dragged down to close.
export function Drawer({
  open,
  onOpenChange,
  title,
  description,
  footer,
  side = "right",
  size = 400,
  className,
  children,
  ...props
}: DrawerProps) {
  const ref = React.useRef<HTMLDialogElement>(null)
  const drag = React.useRef<{ start: number; offset: number } | null>(null)
  const titleId = React.useId()

  React.useEffect(() => {
    const dialog = ref.current
    if (!dialog) return
    if (open && !dialog.open) dialog.showModal()
    else if (!open && dialog.open) dialog.close()
  }, [open])

  const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
    if (side !== "bottom") return
    event.currentTarget.setPointerCapture(event.pointerId)
    drag.current = { start: event.clientY, offset: 0 }
  }
  const onPointerMove = (event: React.PointerEvent) => {
    const dialog = ref.current
    if (!drag.current || !dialog) return
    drag.current.offset = Math.max(0, event.clientY - drag.current.start)
    dialog.style.transition = "none"
    dialog.style.translate = `0 ${drag.current.offset}px`
  }
  const onPointerUp = () => {
    const dialog = ref.current
    if (!drag.current || !dialog) return
    const dismiss = drag.current.offset > dialog.offsetHeight * 0.3
    drag.current = null
    dialog.style.transition = ""
    dialog.style.translate = ""
    if (dismiss) onOpenChange(false)
  }

  return (
    <dialog
      ref={ref}
      aria-labelledby={titleId}
      onCancel={(event) => {
        event.preventDefault()
        onOpenChange(false)
      }}
      onClick={(event) => event.target === event.currentTarget && onOpenChange(false)}
      style={side === "bottom" ? { height: size, maxHeight: "90dvh" } : { width: size, maxWidth: "calc(100vw - 2rem)" }}
      className={cn(
        "m-0 flex-col bg-background p-0 text-foreground shadow-2xl open:flex",
        "transition-[translate,display,overlay] transition-discrete duration-300 ease-[cubic-bezier(.32,.72,0,1)]",
        "backdrop:bg-black/0 backdrop:transition-[background-color,display,overlay] backdrop:transition-discrete backdrop:duration-300 open:backdrop:bg-black/40 starting:open:backdrop:bg-black/0",
        placement[side],
        className
      )}
      {...props}
    >
      {side === "bottom" && (
        <div
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={onPointerUp}
          onPointerCancel={onPointerUp}
          className="flex shrink-0 cursor-grab touch-none justify-center pt-3 pb-1 active:cursor-grabbing"
        >
          <span className="h-1.5 w-12 rounded-full bg-muted-foreground/30" />
        </div>
      )}
      <header className="flex shrink-0 items-start justify-between gap-4 border-b px-6 py-4">
        <div className="flex flex-col gap-1">
          <h2 id={titleId} className="text-base font-semibold">
            {title}
          </h2>
          {description && <p className="text-sm text-muted-foreground">{description}</p>}
        </div>
        <button
          type="button"
          onClick={() => onOpenChange(false)}
          aria-label="Close"
          className="-mr-2 flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
        >
          <X className="size-4" />
        </button>
      </header>
      <div className="min-h-0 flex-1 overflow-y-auto px-6 py-5">{children}</div>
      {footer && <footer className="flex shrink-0 justify-end gap-2 border-t px-6 py-4">{footer}</footer>}
    </dialog>
  )
}
```
