{"slug":"drawer","name":"Drawer","description":"Side panel that slides in from the left or right with a spring, backdrop blur, body scroll lock and esc-to-close.","category":"motion","source_url":"https://beui.dev/r/drawer/raw","detail_url":"https://beui.dev/r/drawer","raw_url":"https://beui.dev/r/drawer/raw","page_url":"https://beui.dev/components/motion/drawer","markdown_url":"https://beui.dev/components/motion/drawer.md","published_at":"2026-06-22","updated_at":"2026-08-20","dependencies":["clsx","motion","react","tailwind-merge"],"internal":["@/components/motion/drawer","@/lib/ease","@/lib/presence-gate","@/lib/utils"],"files":[{"path":"components/motion/drawer.tsx","type":"component","content":"\"use client\";\n// beui.dev/components/motion/drawer\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, type ReactNode } from \"react\";\nimport { EASE_OUT, SPRING_PANEL } from \"@/lib/ease\";\nimport { PresenceGate } from \"@/lib/presence-gate\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface DrawerProps {\n  open: boolean;\n  onOpenChange: (open: boolean) => void;\n  side?: \"left\" | \"right\";\n  children: ReactNode;\n  /** Class for the panel surface. */\n  className?: string;\n  /** Class for the backdrop. */\n  backdropClassName?: string;\n  ariaLabel?: string;\n  /** Close when the backdrop is clicked. Default true. */\n  dismissable?: boolean;\n}\n\nexport function Drawer({\n  open,\n  onOpenChange,\n  side = \"right\",\n  children,\n  className,\n  backdropClassName,\n  ariaLabel,\n  dismissable = true,\n}: DrawerProps) {\n  const reduce = useReducedMotion();\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") onOpenChange(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    const prevOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      document.body.style.overflow = prevOverflow;\n    };\n  }, [open, onOpenChange]);\n\n  const offscreen = side === \"right\" ? \"100%\" : \"-100%\";\n\n  // Two fixed siblings, no wrapper: the backdrop spans the viewport edges but\n  // paints the scrim, and the panel is inset off one side and paints its own\n  // surface, so neither is a transparent edge-spanning layer. Both hang off\n  // `PresenceGate`, so interaction releases in the same commit that starts the\n  // exit rather than when it ends.\n  return (\n    <AnimatePresence>\n      {open ? (\n        <PresenceGate key=\"backdrop\">\n          {({ gate }) => (\n            <motion.button\n              type=\"button\"\n              aria-label=\"Close\"\n              tabIndex={dismissable ? 0 : -1}\n              onClick={() => dismissable && onOpenChange(false)}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ duration: 0.25, ease: EASE_OUT }}\n              {...gate}\n              className={cn(\n                \"fixed inset-0 z-50 h-full w-full cursor-default bg-black/40 backdrop-blur-sm\",\n                backdropClassName,\n              )}\n            />\n          )}\n        </PresenceGate>\n      ) : null}\n      {open ? (\n        <PresenceGate key=\"panel\">\n          {({ gate }) => (\n            <motion.aside\n              role=\"dialog\"\n              aria-modal=\"true\"\n              aria-label={ariaLabel}\n              initial={reduce ? { opacity: 0 } : { x: offscreen }}\n              animate={reduce ? { opacity: 1 } : { x: 0 }}\n              exit={reduce ? { opacity: 0 } : { x: offscreen }}\n              transition={\n                reduce ? { duration: 0.2, ease: EASE_OUT } : SPRING_PANEL\n              }\n              {...gate}\n              className={cn(\n                \"fixed inset-y-0 z-50 flex w-80 max-w-[85vw] flex-col bg-background shadow-2xl\",\n                side === \"right\"\n                  ? \"right-0 border-l border-border\"\n                  : \"left-0 border-r border-border\",\n                className,\n              )}\n            >\n              {children}\n            </motion.aside>\n          )}\n        </PresenceGate>\n      ) : null}\n    </AnimatePresence>\n  );\n}\n"},{"path":"lib/ease.ts","type":"util","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/presence-gate.tsx","type":"util","content":"\"use client\";\n\nimport { useIsPresent } from \"motion/react\";\nimport type { ReactNode } from \"react\";\n\nexport interface PresenceGateRenderProps {\n  /**\n   * False from the render that starts the exit animation onward. An overlay\n   * kept in the tree by `AnimatePresence` is still the topmost thing on the\n   * page, so anything it decides from `open` alone stays true for the whole\n   * exit — this is the boolean that already knows the overlay is leaving.\n   */\n  isPresent: boolean;\n  /**\n   * Spread onto every layer that takes pointer events while the overlay is\n   * open. Interaction releases in the same commit that starts the exit while\n   * the visual exit keeps playing: pointer events stop landing, and `inert`\n   * drops the subtree from focus order, from tab order and from the\n   * accessibility tree — an exiting dialog is not a dialog you can still type\n   * into. A layer that never takes pointer events (a wrapper that only centres\n   * the panel) takes `inert={!isPresent}` alone, so its own\n   * `pointer-events-none` is not overwritten.\n   */\n  gate: {\n    inert: boolean;\n    style: { pointerEvents: \"auto\" | \"none\" };\n  };\n}\n\nexport interface PresenceGateProps {\n  children: (props: PresenceGateRenderProps) => ReactNode;\n}\n\n/**\n * Reads the presence of the subtree it renders and hands it down.\n *\n * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the\n * components that own an overlay render the `AnimatePresence` themselves, so\n * the boolean has to be read one component further down: this is that\n * component, and the render prop is how it reaches the layers.\n */\nexport function PresenceGate({ children }: PresenceGateProps) {\n  const isPresent = useIsPresent();\n\n  return children({\n    isPresent,\n    gate: {\n      inert: !isPresent,\n      style: { pointerEvents: isPresent ? \"auto\" : \"none\" },\n    },\n  });\n}\n"},{"path":"lib/utils.ts","type":"util","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"components/previews/motion/drawer.preview.tsx","type":"preview","content":"\"use client\";\n\nimport { useState } from \"react\";\nimport { Drawer } from \"@/components/motion/drawer\";\n\nexport function DrawerPreview() {\n  const [open, setOpen] = useState(false);\n  const [side, setSide] = useState<\"left\" | \"right\">(\"right\");\n\n  const openWith = (s: \"left\" | \"right\") => {\n    setSide(s);\n    setOpen(true);\n  };\n\n  return (\n    <div className=\"flex items-center gap-3\">\n      <button\n        type=\"button\"\n        onClick={() => openWith(\"left\")}\n        className=\"inline-flex h-10 items-center rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground transition-colors hover:bg-card/70\"\n      >\n        Open left\n      </button>\n      <button\n        type=\"button\"\n        onClick={() => openWith(\"right\")}\n        className=\"inline-flex h-10 items-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90\"\n      >\n        Open right\n      </button>\n\n      <Drawer\n        open={open}\n        onOpenChange={setOpen}\n        side={side}\n        ariaLabel=\"Demo drawer\"\n        className=\"gap-4 p-6\"\n      >\n        <h2 className=\"text-sm font-semibold text-foreground\">Drawer</h2>\n        <p className=\"text-sm text-muted-foreground\">\n          Slides in from the {side}. Press Esc or click outside to close.\n        </p>\n      </Drawer>\n    </div>\n  );\n}\n"}]}