Drawer
A side panel rendered via portal. Slides in from the left or right edge. Supports scrollable content, Escape to close, and four width sizes.
AccessibleDark ModePortal4 SizesLeft / RightScrollable
01
Theme Preview
Drawer panel rendered inline — forced light and dark styling for direct comparison.
Light
Notification settings
Manage how you receive updates.
Email notifications
you@example.com
Push notifications
Mobile & Desktop
Weekly digest
Every Monday 9am
Dark
Notification settings
Manage how you receive updates.
Email notifications
you@example.com
Push notifications
Mobile & Desktop
Weekly digest
Every Monday 9am
02
Live Demo
Size
Side
Click a button to open a drawer.
03
Code Snippet
src/ui/Drawer.tsx
import { Drawer } from "@/ui/Drawer"; import { Button } from "@/ui/Button"; const [open, setOpen] = useState(false); <Button onClick={() => setOpen(true)}>Open drawer</Button> <Drawer.Root open={open} onClose={() => setOpen(false)} size="md" side="right"> <Drawer.Header> <Drawer.Title>Notification settings</Drawer.Title> <Drawer.Description> Manage how you receive updates. </Drawer.Description> </Drawer.Header> <Drawer.Content> {/* scrollable body */} </Drawer.Content> <Drawer.Footer> <Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="primary" onClick={() => setOpen(false)}>Save</Button> </Drawer.Footer> </Drawer.Root> // Sizes: "sm" | "md" | "lg" | "full" // Sides: "right" | "left"
Flat exports seperti DrawerHeader, DrawerContent, dan lainnya tetap didukung untuk migrasi bertahap.
04
Copy-Paste (Single File)
Snippet ini self-contained dan sudah mencakup portal, animation mount, serta primitive sub-components.
Drawer.tsx
"use client"; import { useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from "react"; import { createPortal } from "react-dom"; type ClassValue = string | false | null | undefined; const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" "); type DrawerSide = "right" | "left"; type DrawerSize = "sm" | "md" | "lg" | "full"; interface DrawerProps { open: boolean; onClose: () => void; side?: DrawerSide; size?: DrawerSize; children: ReactNode; className?: string; } const SIZE_CLASSES: Record<DrawerSize, string> = { sm: "w-80", md: "w-96", lg: "w-lg", full: "w-full", }; const SIDE_CLASSES: Record<DrawerSide, { panel: string; hidden: string }> = { right: { panel: "right-0 inset-y-0", hidden: "translate-x-full" }, left: { panel: "left-0 inset-y-0", hidden: "-translate-x-full" }, }; function useAnimatedMount(open: boolean, durationMs = 300) { const [mounted, setMounted] = useState(open); const [visible, setVisible] = useState(open); useEffect(() => { if (open) { setMounted(true); requestAnimationFrame(() => setVisible(true)); return; } setVisible(false); const timer = window.setTimeout(() => setMounted(false), durationMs); return () => window.clearTimeout(timer); }, [open, durationMs]); return { mounted, visible }; } function DrawerHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("border-b border-slate-200 px-6 pt-6 pb-4", className)} {...props}>{children}</div>; } function DrawerTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) { return <h2 className={cn("pr-8 text-lg font-semibold text-slate-900", className)} {...props}>{children}</h2>; } function DrawerDescription({ className, children, ...props }: HTMLAttributes<HTMLParagraphElement>) { return <p className={cn("mt-1 text-sm text-slate-500", className)} {...props}>{children}</p>; } function DrawerContent({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("flex-1 overflow-y-auto px-6 py-4", className)} {...props}>{children}</div>; } function DrawerFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4", className)} {...props}>{children}</div>; } function DrawerRoot({ open, onClose, side = "right", size = "md", children, className }: DrawerProps) { const backdropRef = useRef<HTMLDivElement>(null); const { mounted, visible } = useAnimatedMount(open, 300); useEffect(() => { if (!open) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; document.addEventListener("keydown", onKeyDown); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKeyDown); document.body.style.overflow = ""; }; }, [open, onClose]); if (!mounted) return null; const { panel, hidden } = SIDE_CLASSES[side]; return createPortal( <div ref={backdropRef} className="fixed inset-0 z-50 flex" onClick={(event) => { if (event.target === backdropRef.current) onClose(); }} > <div className={cn("absolute inset-0 bg-black/50 transition-opacity duration-300", visible ? "opacity-100" : "opacity-0")} /> <div role="dialog" aria-modal="true" className={cn( "absolute flex h-full flex-col border-slate-200 bg-white shadow-2xl shadow-black/20 transition-transform duration-300", side === "right" ? "border-l" : "border-r", visible ? "translate-x-0" : hidden, SIZE_CLASSES[size], panel, className )} > <button onClick={onClose} className="absolute right-4 top-4 rounded-lg p-1.5 text-slate-400 hover:bg-slate-100" aria-label="Close drawer">×</button> {children} </div> </div>, document.body ); } type DrawerCompound = typeof DrawerRoot & { Root: typeof DrawerRoot; Header: typeof DrawerHeader; Title: typeof DrawerTitle; Description: typeof DrawerDescription; Content: typeof DrawerContent; Footer: typeof DrawerFooter; }; export const Drawer = Object.assign(DrawerRoot, { Root: DrawerRoot, Header: DrawerHeader, Title: DrawerTitle, Description: DrawerDescription, Content: DrawerContent, Footer: DrawerFooter, }) as DrawerCompound;
05
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controls drawer visibility. |
onClose | () => void | — | Called on Escape, backdrop click, or × button. |
side | "right" | "left" | "right" | Edge the drawer slides in from. |
size | "sm" | "md" | "lg" | "full" | "md" | Controls width of the drawer panel. |
children | ReactNode | — | Drawer content, typically composed with sub-components. |
className | string | — | Extra classes applied to the drawer panel. |