Drawer
Side sheet that slides in and closes on Esc or swipe — shown as a cart.
components/ui/drawer.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { ShoppingBag, X } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
type DrawerProps = {
open: boolean;
onClose: () => void;
title: string;
side?: "right" | "left";
children: ReactNode;
};
export function Drawer({ open, onClose, title, side = "right", children }: DrawerProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
if (!mounted) return null;
const from = side === "right" ? "100%" : "-100%";
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[100]">
<motion.div className="absolute inset-0 bg-zinc-900/30" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} />
<motion.aside
role="dialog"
aria-modal="true"
aria-label={title}
initial={{ x: from }}
animate={{ x: 0 }}
exit={{ x: from }}
transition={{ type: "spring", stiffness: 360, damping: 36 }}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={{ left: side === "left" ? 0.6 : 0, right: side === "right" ? 0.6 : 0 }}
onDragEnd={(_, info) => {
if ((side === "right" && info.offset.x > 120) || (side === "left" && info.offset.x < -120)) onClose();
}}
className={`absolute inset-y-0 flex w-full max-w-sm flex-col bg-white shadow-2xl ${side === "right" ? "right-0" : "left-0"}`}
>
<div className="flex items-center justify-between border-b border-zinc-100 px-6 py-4">
<h2 className="font-semibold text-zinc-900">{title}</h2>
<button onClick={onClose} aria-label="Close" className="grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700">
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">{children}</div>
</motion.aside>
</div>
)}
</AnimatePresence>,
document.body,
);
}
const cart = [
{ name: "Wireless headphones", price: 129, color: "from-zinc-700 to-zinc-900" },
{ name: "Leather notebook", price: 32, color: "from-amber-500 to-orange-700" },
];
export default function Demo() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-zinc-800">
<ShoppingBag className="size-4" /> Open cart (2)
</button>
<Drawer open={open} onClose={() => setOpen(false)} title="Your cart">
<ul className="space-y-4">
{cart.map((c) => (
<li key={c.name} className="flex items-center gap-4">
<span className={`size-16 rounded-xl bg-gradient-to-br ${c.color}`} />
<div className="flex-1">
<p className="text-sm font-medium text-zinc-900">{c.name}</p>
<p className="text-sm text-zinc-500">Qty 1</p>
</div>
<p className="text-sm font-medium text-zinc-900">${c.price}</p>
</li>
))}
</ul>
<div className="mt-8 border-t border-zinc-100 pt-4">
<div className="flex justify-between text-sm font-medium text-zinc-900">
<span>Subtotal</span>
<span>$161</span>
</div>
<button className="mt-4 w-full rounded-lg bg-zinc-900 py-3 text-sm font-medium text-white hover:bg-zinc-800">Checkout</button>
<p className="mt-3 text-center text-xs text-zinc-400">Swipe right or press Esc to close</p>
</div>
</Drawer>
</>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/drawer.tsxcomponents/ui/drawer.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { ShoppingBag, X } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; type DrawerProps = { open: boolean; onClose: () => void; title: string; side?: "right" | "left"; children: ReactNode; }; export function Drawer({ open, onClose, title, side = "right", children }: DrawerProps) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; }, [open, onClose]); if (!mounted) return null; const from = side === "right" ? "100%" : "-100%"; return createPortal( <AnimatePresence> {open && ( <div className="fixed inset-0 z-[100]"> <motion.div className="absolute inset-0 bg-zinc-900/30" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} /> <motion.aside role="dialog" aria-modal="true" aria-label={title} initial={{ x: from }} animate={{ x: 0 }} exit={{ x: from }} transition={{ type: "spring", stiffness: 360, damping: 36 }} drag="x" dragConstraints={{ left: 0, right: 0 }} dragElastic={{ left: side === "left" ? 0.6 : 0, right: side === "right" ? 0.6 : 0 }} onDragEnd={(_, info) => { if ((side === "right" && info.offset.x > 120) || (side === "left" && info.offset.x < -120)) onClose(); }} className={`absolute inset-y-0 flex w-full max-w-sm flex-col bg-white shadow-2xl ${side === "right" ? "right-0" : "left-0"}`} > <div className="flex items-center justify-between border-b border-zinc-100 px-6 py-4"> <h2 className="font-semibold text-zinc-900">{title}</h2> <button onClick={onClose} aria-label="Close" className="grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"> <X className="size-4" /> </button> </div> <div className="flex-1 overflow-y-auto p-6">{children}</div> </motion.aside> </div> )} </AnimatePresence>, document.body, ); } const cart = [ { name: "Wireless headphones", price: 129, color: "from-zinc-700 to-zinc-900" }, { name: "Leather notebook", price: 32, color: "from-amber-500 to-orange-700" }, ]; export default function Demo() { const [open, setOpen] = useState(false); return ( <> <button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 rounded-lg bg-zinc-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-zinc-800"> <ShoppingBag className="size-4" /> Open cart (2) </button> <Drawer open={open} onClose={() => setOpen(false)} title="Your cart"> <ul className="space-y-4"> {cart.map((c) => ( <li key={c.name} className="flex items-center gap-4"> <span className={`size-16 rounded-xl bg-gradient-to-br ${c.color}`} /> <div className="flex-1"> <p className="text-sm font-medium text-zinc-900">{c.name}</p> <p className="text-sm text-zinc-500">Qty 1</p> </div> <p className="text-sm font-medium text-zinc-900">${c.price}</p> </li> ))} </ul> <div className="mt-8 border-t border-zinc-100 pt-4"> <div className="flex justify-between text-sm font-medium text-zinc-900"> <span>Subtotal</span> <span>$161</span> </div> <button className="mt-4 w-full rounded-lg bg-zinc-900 py-3 text-sm font-medium text-white hover:bg-zinc-800">Checkout</button> <p className="mt-3 text-center text-xs text-zinc-400">Swipe right or press Esc to close</p> </div> </Drawer> </> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.