Building a Robust React Drawer Navigation Component (Web)

Build an accessible, responsive, high‑performance React drawer navigation component with code, a11y tips, and production checklists.

ASOasis
7 min read
Building a Robust React Drawer Navigation Component (Web)

Image used for representation purposes only.

Overview

Drawer navigation is a staple pattern for progressive disclosure: it keeps primary tasks visible while hiding secondary navigation and filters until they’re needed. In React, a well‑built drawer must be accessible, responsive, and performant—without fighting your routing or state management.

This guide walks through UX choices, accessibility requirements, a from‑scratch implementation, and production tips. Examples target React for the web, with notes for React Native at the end.

When (and When Not) To Use a Drawer

Use a drawer when:

  • You have more than 5–7 secondary destinations or filters that don’t warrant permanent screen real estate.
  • Content benefits from full width, especially on mobile, and navigation can be temporarily hidden.
  • You want a progressive path from quick icon access (mini variant) to full navigation labels (expanded).

Avoid a drawer when:

  • Primary navigation must always be visible (e.g., mission‑critical dashboards).
  • You have very few destinations—tabs or a top bar may be clearer.
  • The workflow is linear; a wizard/stepper is a better fit.

Common Drawer Variants

  • Temporary (modal/overlay): Slides over content; blocks background. Great on mobile.
  • Persistent: Sits beside content on desktop; doesn’t block background.
  • Mini variant: Collapsed rail with icons; expands on hover or toggle.
  • Responsive hybrid: Temporary under 1024px, persistent above.

Anatomy

  • Trigger: Button in the app bar or a hamburger/“Filters” button.
  • Panel: The sliding container.
  • Scrim/overlay: A backdrop for temporary drawers.
  • Content: Navigation list, section labels, optional user/account area.
  • Close affordance: Dedicated “Close” button, plus overlay click and Esc key.
  • Focus management: Initial focus target; focus trap; return focus on close.

Accessibility Checklist

For a temporary (modal) drawer:

  • Role and labeling: role=“dialog” with aria-modal=“true”. Provide aria-labelledby pointing to the drawer title, or aria-label for a concise name.
  • Focus: Move focus into the drawer on open; trap focus inside; return focus to the trigger on close.
  • Keyboard: Support Esc to close; Tab/Shift+Tab cycle within the drawer.
  • Background: Make the page behind inert (use the inert attribute if available) or set aria-hidden and disable pointer events.
  • Contrast and targets: Meet WCAG color contrast; 44×44 px targets on touch.

For a persistent (non-modal) drawer:

  • Use a semantic
  • No focus trap; content remains interactive.

Respect reduced motion: Prefer opacity/transform transitions; let users opt out with @media (prefers-reduced-motion: reduce).

From-Scratch React Drawer (Headless)

The snippet below implements a temporary (modal) left drawer with a focus trap, Esc handling, overlay click‑to‑close, and return‑focus behavior. For production, consider a proven focus‑trap utility, but this shows the moving parts clearly.

import React, {useEffect, useRef} from 'react';
import {createPortal} from 'react-dom';

type DrawerProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  title?: string;
  children: React.ReactNode;
};

export function Drawer({ open, onOpenChange, title = 'Navigation', children }: DrawerProps) {
  const overlayRef = useRef<HTMLDivElement | null>(null);
  const panelRef = useRef<HTMLDivElement | null>(null);
  const lastFocused = useRef<HTMLElement | null>(null);

  // Move focus in, trap it, and restore on close
  useEffect(() => {
    const root = document.getElementById('root') || document.body;

    if (open) {
      lastFocused.current = (document.activeElement as HTMLElement) || null;
      // Inert background if supported
      if ('inert' in HTMLElement.prototype) (root as any).inert = true;

      // Focus first focusable element or panel
      const focusable = panelRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      (focusable || panelRef.current)?.focus();

      const onKeyDown = (e: KeyboardEvent) => {
        if (e.key === 'Escape') onOpenChange(false);
        if (e.key === 'Tab') {
          // naive focus trap
          const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          );
          if (!focusables || focusables.length === 0) return;
          const first = focusables[0];
          const last = focusables[focusables.length - 1];
          const active = document.activeElement as HTMLElement;
          if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
          if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
        }
      };

      document.addEventListener('keydown', onKeyDown);
      return () => {
        document.removeEventListener('keydown', onKeyDown);
      };
    } else {
      // Restore focus and remove inert
      if ('inert' in HTMLElement.prototype) (root as any).inert = false;
      lastFocused.current?.focus?.();
    }
  }, [open, onOpenChange]);

  if (!open) return null;

  return createPortal(
    <div
      ref={overlayRef}
      className="drawer-overlay"
      aria-hidden
      onMouseDown={(e) => {
        // Only close when clicking backdrop, not the panel
        if (e.target === overlayRef.current) onOpenChange(false);
      }}
    >
      <div
        ref={panelRef}
        className="drawer-panel"
        role="dialog"
        aria-modal="true"
        aria-labelledby="drawer-title"
        tabIndex={-1}
      >
        <header className="drawer-header">
          <h2 id="drawer-title">{title}</h2>
          <button aria-label="Close" onClick={() => onOpenChange(false)}></button>
        </header>
        <nav aria-label="Drawer">
          {children}
        </nav>
      </div>
    </div>,
    document.body
  );
}

Basic styles and motion:

.drawer-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,.5);
  display: grid; grid-template-columns: 1fr; place-items: stretch;
}
.drawer-panel {
  width: min(85vw, 360px);
  height: 100dvh; /* mobile safe viewport */
  background: var(--surface, #fff);
  color: var(--text, #111);
  box-shadow: 0 10px 30px rgba(0,0,0,.2);
  transform: translateX(-100%);
  animation: slide-in .2s ease-out forwards;
}
.drawer-header { display:flex; align-items:center; justify-content:space-between; padding: 1rem; }

@keyframes slide-in { from { transform: translateX(-100%); } to { transform: translateX(0); } }

@media (prefers-reduced-motion: reduce) {
  .drawer-panel { animation: none; transform: none; }
}

Usage:

function App() {
  const [open, setOpen] = React.useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)} aria-haspopup="dialog" aria-controls="drawer">Menu</button>
      <Drawer open={open} onOpenChange={setOpen} title="Main menu">
        <ul>
          <li><a href="/dashboard">Dashboard</a></li>
          <li><a href="/reports">Reports</a></li>
          <li><a href="/settings">Settings</a></li>
        </ul>
      </Drawer>
    </>
  );
}

Notes:

  • Prefer a battle‑tested focus trap (e.g., focus-trap) in production.
  • Consider portal + z-index layering to avoid stacking-context bugs.
  • Add body scroll lock on open (overflow: hidden on html/body or use a library) to prevent background scroll.

Controlled vs. Uncontrolled API

  • Controlled: is predictable, integrates with global state, and plays nicely with routers.
  • Uncontrolled: Internally tracks open state, simpler to drop in, but harder to synchronize with URL changes. If you must, also expose onOpenChange.

Recommended signature:

  • props: open, defaultOpen, onOpenChange, side = “left” | “right”, modal = boolean, closeOnOverlayClick = boolean, closeOnEsc = boolean.

Router Integration

  • Close the drawer on route change to avoid stale navigation state.
  • In React Router, listen to location changes; in Next.js, subscribe to router.events.
  • If the drawer controls filters instead of navigation, consider encoding open state or filter state in the URL for shareability.

Responsive Behavior

  • Use a CSS clamp for width and switch variants at breakpoints:
    • Under 1024px: temporary/modal.
    • 1024px and up: persistent or mini variant.
  • Respect safe areas: padding-left: env(safe-area-inset-left) on iOS.

Animation and Feel

  • Use transform: translateX for GPU‑friendly motion.
  • Keep duration short (150–220ms) with ease-out for open, ease-in for close.
  • Stagger nav items subtly for polish, but disable when prefers-reduced-motion is set.

Using a Component Library

If you prefer to skip headless wiring, libraries offer robust drawers out of the box.

Material UI (MUI):

import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';

function MuiExample({ open, setOpen }: {open: boolean; setOpen: (v:boolean)=>void}) {
  return (
    <Drawer anchor="left" open={open} onClose={() => setOpen(false)} ModalProps={{ keepMounted: true }}>
      <List sx={{ width: 320 }}>
        {/* ListItemButton + ListItemIcon + ListItemText */}
      </List>
    </Drawer>
  );
}

Radix UI (Dialog as a side sheet):

import * as Dialog from '@radix-ui/react-dialog';

export function RadixDrawer() {
  return (
    <Dialog.Root>
      <Dialog.Trigger>Menu</Dialog.Trigger>
      <Dialog.Portal>
        <Dialog.Overlay className="overlay" />
        <Dialog.Content className="sheet-left" aria-label="Navigation">
          <Dialog.Title>Main menu</Dialog.Title>
          {/* Nav items */}
          <Dialog.Close>Close</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

Other good options: Headless UI’s Dialog, Ark UI/React Aria Components for fully accessible primitives.

Performance Tips

  • Keep the drawer mounted (keepMounted) and toggle visibility with CSS for snappy re‑opens.
  • Memoize large nav lists; avoid re‑creating arrays/objects on every render.
  • Defer heavy content until the drawer opens (lazy load settings, avatars, etc.).
  • Use will-change: transform during animations only to avoid long‑lived memory costs.

Testing Strategy

  • Unit/integration (React Testing Library):
    • Open via trigger; assert role=“dialog” and aria-modal; verify focus is inside.
    • Press Esc and expect onOpenChange(false).
    • Tab through and ensure focus cycles.
  • E2E (Playwright/Cypress):
    • Check overlay click closes drawer.
    • Validate mobile breakpoint behavior in viewport emulation.
  • Accessibility (axe/Storybook a11y):
    • No color contrast or ARIA violations.

Common Gotchas

  • Body scroll lock on iOS: apply overflow: hidden to body and consider position: fixed on the app shell to prevent rubber‑banding.
  • Stacking context: portals avoid z-index fights with transformed parents.
  • Focus restoration: store last activeElement before opening, return to it on close.
  • Background interactivity: use inert where available; otherwise combine aria-hidden with pointer-events: none.

Production Checklist

  • Keyboard: Tab trap, Shift+Tab, Esc.
  • Screen reader: role/labeling, focus on open, return focus on close.
  • Visual: contrast, large touch targets, reduced motion.
  • Performance: keepMounted or lazy load; memoize nav items.
  • Responsive: temporary on mobile, persistent on desktop.
  • Router: close on navigation.

React Native Note

For React Native, use @react-navigation/drawer for a platform‑appropriate, gesture‑enabled drawer navigator. Many a11y and state principles above still apply, but implementation differs because it’s native UI, not the DOM.

Final Thoughts

A drawer is more than a sliding panel—it’s a carefully orchestrated focus, motion, and state experience. Whether you choose a headless approach or a UI library, following the accessibility and performance practices above will give you a robust, reliable React drawer that feels right on every screen.

Related Posts