Drawer
Side panel that slides in from the left or right with a spring, backdrop blur, body scroll lock and esc-to-close.
Preview
TSXcomponents/previews/motion/drawer.preview.tsx
"use client";
import { useState } from "react";
import { Drawer } from "@/components/motion/drawer";
export function DrawerPreview() {
const [open, setOpen] = useState(false);
const [side, setSide] = useState<"left" | "right">("right");
const openWith = (s: "left" | "right") => {
setSide(s);
setOpen(true);
};
return (
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => openWith("left")}
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"
>
Open left
</button>
<button
type="button"
onClick={() => openWith("right")}
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"
>
Open right
</button>
<Drawer
open={open}
onOpenChange={setOpen}
side={side}
ariaLabel="Demo drawer"
className="gap-4 p-6"
>
<h2 className="text-sm font-semibold text-foreground">Drawer</h2>
<p className="text-sm text-muted-foreground">
Slides in from the {side}. Press Esc or click outside to close.
</p>
</Drawer>
</div>
);
}
TSXcomponents/motion/drawer.tsx
"use client";
// beui.dev/components/motion/drawer
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, type ReactNode } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: "left" | "right";
children: ReactNode;
/** Class for the panel surface. */
className?: string;
/** Class for the backdrop. */
backdropClassName?: string;
ariaLabel?: string;
/** Close when the backdrop is clicked. Default true. */
dismissable?: boolean;
}
export function Drawer({
open,
onOpenChange,
side = "right",
children,
className,
backdropClassName,
ariaLabel,
dismissable = true,
}: DrawerProps) {
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onOpenChange]);
const offscreen = side === "right" ? "100%" : "-100%";
return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50">
<motion.button
type="button"
aria-label="Close"
tabIndex={dismissable ? 0 : -1}
onClick={() => dismissable && onOpenChange(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn(
"absolute inset-0 h-full w-full cursor-default bg-black/40 backdrop-blur-sm",
backdropClassName,
)}
/>
<motion.aside
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
initial={reduce ? { opacity: 0 } : { x: offscreen }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={reduce ? { opacity: 0 } : { x: offscreen }}
transition={reduce ? { duration: 0.2, ease: EASE_OUT } : SPRING_PANEL}
className={cn(
"absolute inset-y-0 flex w-80 max-w-[85vw] flex-col bg-background shadow-2xl",
side === "right"
? "right-0 border-l border-border"
: "left-0 border-r border-border",
className,
)}
>
{children}
</motion.aside>
</div>
) : null}
</AnimatePresence>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
$ bunx --bun shadcn add @beui/drawer
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/drawer.tsx
"use client";
// beui.dev/components/motion/drawer
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, type ReactNode } from "react";
import { EASE_OUT, SPRING_PANEL } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface DrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: "left" | "right";
children: ReactNode;
/** Class for the panel surface. */
className?: string;
/** Class for the backdrop. */
backdropClassName?: string;
ariaLabel?: string;
/** Close when the backdrop is clicked. Default true. */
dismissable?: boolean;
}
export function Drawer({
open,
onOpenChange,
side = "right",
children,
className,
backdropClassName,
ariaLabel,
dismissable = true,
}: DrawerProps) {
const reduce = useReducedMotion();
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [open, onOpenChange]);
const offscreen = side === "right" ? "100%" : "-100%";
return (
<AnimatePresence>
{open ? (
<div className="fixed inset-0 z-50">
<motion.button
type="button"
aria-label="Close"
tabIndex={dismissable ? 0 : -1}
onClick={() => dismissable && onOpenChange(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25, ease: EASE_OUT }}
className={cn(
"absolute inset-0 h-full w-full cursor-default bg-black/40 backdrop-blur-sm",
backdropClassName,
)}
/>
<motion.aside
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
initial={reduce ? { opacity: 0 } : { x: offscreen }}
animate={reduce ? { opacity: 1 } : { x: 0 }}
exit={reduce ? { opacity: 0 } : { x: offscreen }}
transition={reduce ? { duration: 0.2, ease: EASE_OUT } : SPRING_PANEL}
className={cn(
"absolute inset-y-0 flex w-80 max-w-[85vw] flex-col bg-background shadow-2xl",
side === "right"
? "right-0 border-l border-border"
: "left-0 border-r border-border",
className,
)}
>
{children}
</motion.aside>
</div>
) : null}
</AnimatePresence>
);
}
API Reference
openboolean—onOpenChange(open: boolean) => void—side?"left" | "right"rightclassName?stringClass for the panel surface.
—backdropClassName?stringClass for the backdrop.
—ariaLabel?string—dismissable?booleanClose when the backdrop is clicked. Default true.
trueKeep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated