Skip to content

Drawer

LG, under 3300B gzipped

A modal panel anchored to a screen edge, using logical start/end sides.

Category
Overlays
Budget
lg, composes several components
Runtime deps
None

Preview

Preview language

This is the exact fixture the conformance suite renders in CI. Switch the language to see the component mirror and reformat.

Install

npx shadcn@latest add https://kata-ui-rho.vercel.app/r/drawer.json

Copies the source into your project. Pulls in 6 primitives: use-focus-trap, cn, use-locale, locale, portal, use-hydrated.

Source

"use client";

import { useEffect, useId } from "react";
import { useFocusTrap } from "../lib/use-focus-trap";
import { cn } from "../lib/cn";
import { useLocale } from "../lib/use-locale";
import { Portal } from "./portal";

export interface DrawerProps {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  /**
   * "start"/"end" rather than "left"/"right": under RTL, "end" is the left
   * edge, and a caller thinking in physical sides gets it backwards for half
   * the world's readers.
   */
  side?: "start" | "end";
  className?: string;
}

/** A modal panel anchored to a screen edge — the same contract as Dialog. */
export function Drawer({ open, onClose, title, children, side = "end", className }: DrawerProps) {
  const titleId = useId();
  const trapRef = useFocusTrap<HTMLDivElement>(open);
  const { direction } = useLocale();

  useEffect(() => {
    if (!open) return;
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") onClose();
    };
    document.addEventListener("keydown", onKeyDown);
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    return () => {
      document.removeEventListener("keydown", onKeyDown);
      document.body.style.overflow = previousOverflow;
    };
  }, [open, onClose]);

  if (!open) return null;

  return (
    <Portal>
      <div className="fixed inset-0 z-50">
        <div aria-hidden="true" className="absolute inset-0 bg-black/50" onClick={onClose} />
        <div
          ref={trapRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          dir={direction}
          tabIndex={-1}
          className={cn(
            "absolute inset-y-0 flex w-full max-w-sm flex-col gap-4 bg-white p-6 text-start shadow-xl dark:bg-neutral-900",
            side === "start" ? "start-0" : "end-0",
            className,
          )}
        >
          <h2 id={titleId} className="text-base font-semibold">
            {title}
          </h2>
          {children}
        </div>
      </div>
    </Portal>
  );
}

What CI checks

← All components