DrawerNav
LG · 3300B gzip budgetSlide-out navigation drawer with focus trap and backdrop.
- Category
- Navigation
- Budget
- lg, composes several components
- External runtime
- React / React DOM only
axe fixtureSSR rendersource scanbundle budget
Preview
Live fixtureNavigation
Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.
Install
npx shadcn@latest add https://gear5-ui.vercel.app/r/drawer-nav.jsonCopies the source into your project. Pulls in 1 shared registry item: cn.
New here? Set up Tailwind and import aliases first →Source & props
Edit on GitHub ↗Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.
"use client";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "../lib/cn";
export interface DrawerNavProps {
open: boolean;
onClose: () => void;
children: React.ReactNode;
side?: "start" | "end";
label?: string;
className?: string;
}
/**
* Slide-in navigation panel with focus trap and `aria-modal="true"`. Focus is
* trapped inside when open, and Escape closes the drawer returning focus to the
* trigger element. Uses logical properties for RTL support.
*/
export function DrawerNav({
open,
onClose,
children,
side = "start",
label = "Navigation",
className,
}: DrawerNavProps) {
const panelRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (open) {
previousFocusRef.current = document.activeElement as HTMLElement;
const panel = panelRef.current;
if (panel) {
const focusable = panel.querySelector<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
focusable?.focus();
}
} else {
previousFocusRef.current?.focus();
}
}, [open]);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Escape") {
onClose();
return;
}
if (event.key !== "Tab" || !panelRef.current) return;
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
},
[onClose],
);
if (!open) return null;
const translateClass = side === "start" ? "rtl:translate-x-full" : "rtl:-translate-x-full";
return (
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
aria-hidden="true"
className="absolute inset-0 bg-black/40"
onClick={onClose}
/>
{/* Panel */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={handleKeyDown}
className={cn(
"absolute inset-y-0 flex w-72 flex-col bg-white shadow-xl transition-transform motion-reduce:transition-none dark:bg-neutral-900",
side === "start" ? "start-0" : "end-0",
translateClass,
className,
)}
>
<div className="flex items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
<span className="text-sm font-semibold">{label}</span>
<button
type="button"
aria-label="Close navigation"
onClick={onClose}
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:hover:bg-neutral-800"
>
<span aria-hidden="true">✕</span>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">{children}</div>
</div>
</div>
);
}
What CI checks
Quality contract- Bundled, minified and gzipped against its tier budget
- Audited by axe in the state previewed above
- Rendered through react-dom/server with no browser globals
- Scanned for network calls, dangerous sinks, and unguarded animation