Drawer
Slide-in panel anchored right/left/bottom for filters, detail views or forms.
import { Drawer }from "@/components/ui"
Specification
A slide-in panel anchored to the right, left or bottom for filters, detail views or forms, with focus trapping and backdrop dismissal.
Props (7)
Every prop this component accepts, its type, default, and how to use it.
| Prop | Type | Default | Description |
|---|---|---|---|
| open* | boolean | — | Controls whether the drawer is rendered. |
| onClose* | () => void | — | Called when the drawer requests to close (Esc, backdrop, or close button). |
| side | "right" | "left" | "bottom" | "right" | Edge the drawer slides in from. |
| title | ReactNode | — | Optional heading; also wires up aria-labelledby. |
| footer | ReactNode | — | Footer content, typically action buttons. |
| closeOnBackdrop | boolean | true | Whether clicking the backdrop closes the drawer. |
| children | ReactNode | — | Main body content of the drawer. |
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
Dependency files
These supporting files aren’t components — copy each one into the matching path above.
src/hooks/use-focus-trap.ts
use-focus-trap.ts
"use client";
import { useEffect, type RefObject } from "react";
const FOCUSABLE =
'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
/**
* useFocusTrap — while `active`, traps Tab focus within `ref`, focuses the
* first focusable element on open, restores focus to the previously focused
* element on close, and calls `onEscape` when Escape is pressed. Also locks
* body scroll while active.
*/
export function useFocusTrap(
ref: RefObject<HTMLElement | null>,
active: boolean,
onEscape?: () => void
) {
useEffect(() => {
if (!active) return;
const node = ref.current;
if (!node) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
// Focus the first focusable element (or the container).
const focusables = () =>
Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
(el) => el.offsetParent !== null
);
const first = focusables()[0];
(first ?? node).focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onEscape?.();
return;
}
if (e.key !== "Tab") return;
const items = focusables();
if (items.length === 0) {
e.preventDefault();
return;
}
const firstEl = items[0];
const lastEl = items[items.length - 1];
if (e.shiftKey && document.activeElement === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && document.activeElement === lastEl) {
e.preventDefault();
firstEl.focus();
}
};
document.addEventListener("keydown", onKeyDown);
// Lock body scroll.
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
previouslyFocused?.focus?.();
};
}, [active, ref, onEscape]);
}src/hooks/use-mounted.ts
use-mounted.ts
"use client";
import { useState, useEffect } from "react";
/**
* Returns true once the component has mounted on the client.
* Useful for portals and anything that needs `document` to exist.
*/
export function useMounted(): boolean {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted;
}src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";
import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";
interface PopoverPositionOptions {
align?: "start" | "center" | "end";
gap?: number;
viewportPadding?: number;
preferredHeight?: number;
matchWidth?: boolean;
minWidth?: number;
}
interface PopoverPosition {
placement: "top" | "bottom";
style: CSSProperties;
}
/**
* Compute a fixed-position popover style anchored to a ref element.
* Opens downward by default; flips upward when there isn't room below
* AND there's more room above. Clamps horizontally so nothing renders
* past the viewport edge. Re-measures on scroll/resize while open.
*/
export function usePopoverPosition(
anchorRef: RefObject<HTMLElement | null>,
open: boolean,
options: PopoverPositionOptions = {}
): PopoverPosition | null {
const {
align = "start",
gap = 4,
viewportPadding = 8,
preferredHeight = 320,
matchWidth = false,
minWidth,
} = options;
const [position, setPosition] = useState<PopoverPosition | null>(null);
const measure = useCallback(() => {
const anchor = anchorRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const spaceBelow = vh - rect.bottom - viewportPadding;
const spaceAbove = rect.top - viewportPadding;
const placement =
spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
? "top"
: "bottom";
const maxHeight = Math.max(
120,
Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
);
const width = matchWidth ? rect.width : minWidth;
let left = align === "end" && width ? rect.right - width : rect.left;
if (align === "center" && width)
left = rect.left + rect.width / 2 - width / 2;
left = width
? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
: Math.min(Math.max(left, viewportPadding), vw - viewportPadding);
setPosition({
placement,
style: {
position: "fixed",
top: placement === "top" ? undefined : rect.bottom + gap,
bottom: placement === "top" ? vh - rect.top + gap : undefined,
left,
width: matchWidth ? rect.width : undefined,
minWidth: !matchWidth ? minWidth : undefined,
maxHeight,
},
});
}, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);
useEffect(() => {
if (!open) {
setPosition(null);
return;
}
measure();
window.addEventListener("scroll", measure, true);
window.addEventListener("resize", measure);
return () => {
window.removeEventListener("scroll", measure, true);
window.removeEventListener("resize", measure);
};
}, [open, measure]);
return position;
}src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/**
* Merge class names with clsx semantics + Tailwind conflict resolution.
* Prefer this in all components.
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
/**
* Concatenate class names, filtering out falsy values.
* @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
*/
export function cx(...args: (string | false | null | undefined)[]): string {
return args.filter(Boolean).join(" ");
}
/**
* Focus ring style helper.
* @deprecated Focus rings are now handled globally via the
* `:focus-visible` rule in `globals.css` and the `focus-visible:*`
* utilities. This remains only for backwards compatibility.
*/
export function ring(color: string = "var(--color-accent)") {
return { outlineColor: color };
}