Drawer

Slide-in panel anchored right/left/bottom for filters, detail views or forms.

import { Drawer }from "@/components/ui"

Right sheet

Full source

The complete component, read from src/components/ui/drawer.tsx.

drawer.tsx
"use client";

import { useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
import { useMounted, useFocusTrap } from "@/hooks";

type DrawerSide = "right" | "left" | "bottom";

const SIDE: Record<DrawerSide, string> = {
  right: "inset-y-0 right-0 h-full w-full max-w-md border-l",
  left: "inset-y-0 left-0 h-full w-full max-w-md border-r",
  bottom: "inset-x-0 bottom-0 max-h-[85vh] w-full rounded-t-2xl border-t",
};

const ENTER: Record<DrawerSide, string> = {
  right: "animate-[drawer-in-right_0.2s_ease]",
  left: "animate-[drawer-in-left_0.2s_ease]",
  bottom: "animate-[drawer-in-bottom_0.2s_ease]",
};

interface DrawerProps {
  open: boolean;
  onClose: () => void;
  side?: DrawerSide;
  title?: ReactNode;
  footer?: ReactNode;
  closeOnBackdrop?: boolean;
  children?: ReactNode;
}

export function Drawer({
  open,
  onClose,
  side = "right",
  title,
  footer,
  closeOnBackdrop = true,
  children,
}: DrawerProps) {
  const panelRef = useRef<HTMLDivElement>(null);
  const mounted = useMounted();
  useFocusTrap(panelRef, open, onClose);

  if (!open || !mounted) return null;

  return createPortal(
    <div
      className="fixed inset-0 z-[1000] bg-overlay"
      onClick={closeOnBackdrop ? onClose : undefined}
    >
      <div
        ref={panelRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={title ? "drawer-title" : undefined}
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
        className={cn(
          "absolute flex flex-col bg-surface shadow-overlay outline-none border-border",
          SIDE[side],
          ENTER[side]
        )}
      >
        <div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4">
          {title ? (
            <h2 id="drawer-title" className="text-base font-semibold text-text">
              {title}
            </h2>
          ) : (
            <span />
          )}
          <button
            type="button"
            onClick={onClose}
            aria-label="Close"
            className="-mr-1 shrink-0 rounded-md p-1 text-text-tertiary hover:bg-bg-hover hover:text-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
          >
            <X size={18} aria-hidden />
          </button>
        </div>

        <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm text-text">{children}</div>

        {footer && (
          <div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3">
            {footer}
          </div>
        )}
      </div>
    </div>,
    document.body
  );
}

Dependencies

To use Drawer you also need the following. Copy these into your project alongside it.

Related components

Recommended folder structure

project structure
src/components/ui/
  ├─ button.tsx
  └─ drawer.tsx
src/hooks/
  ├─ use-focus-trap.ts
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts