GitHub

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

Active

Push notifications

Mobile & Desktop

Active

Weekly digest

Every Monday 9am

Off
Dark

Notification settings

Manage how you receive updates.

Email notifications

you@example.com

Active

Push notifications

Mobile & Desktop

Active

Weekly digest

Every Monday 9am

Off
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

PropTypeDefaultDescription
openbooleanControls drawer visibility.
onClose() => voidCalled 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.
childrenReactNodeDrawer content, typically composed with sub-components.
classNamestringExtra classes applied to the drawer panel.
react-principles