UIOverlays and Menusmotion/react

Drawer

A modal side sheet that springs in from any edge of the viewport over a blurred backdrop, with a title, description, scrollable body and footer actions. Focus is trapped while open and restored to the trigger on close.

Preview

Customize

11 props
380
4

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add drawer

Writes components/ui/drawer.tsx and components/styles/drawer.module.css.

Usage

Updates live as you change the controls — only props that differ from the defaults are included.

example.tsx
1import { Drawer } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Drawer />6  );7}

Props

PropTypeDefault
open

Controlled open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called whenever the drawer requests to open or close.

(open: boolean) => voidundefined
side

Viewport edge the drawer slides in from.

'left' | 'right' | 'top' | 'bottom''right'
size

Panel width (left/right) or height (top/bottom) in pixels, capped to the viewport.

number380
triggerLabel

Label of the trigger button.

string'Open settings'
title

Drawer title, used as the accessible name.

string'Notifications'
description

Supporting text under the title, used as the accessible description.

string'Choose what you want to hear about. Changes apply to all your devices.'
children

Body content; defaults to a list of notification switches.

React.ReactNodenotification settings
confirmLabel

Label of the primary action.

string'Save preferences'
cancelLabel

Label of the secondary action.

string'Cancel'
onConfirm

Called when the primary action is pressed; the drawer then closes.

() => voidundefined
accentColor

Color of the primary action and switches.

string'#6366f1'
backdropBlur

Backdrop blur radius in pixels.

number4
closeOnOutsideClick

Close the drawer when the backdrop is clicked.

booleantrue
showCloseButton

Show the X close button in the top-right corner.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

Accessibility

Implements the WAI-ARIA modal dialog pattern: role="dialog" with aria-modal, aria-labelledby (title) and aria-describedby (description). The trigger exposes aria-haspopup="dialog" and aria-expanded. Focus moves to the first control on open, Tab/Shift+Tab cycle inside the panel, Escape and backdrop click close it, and focus returns to the trigger. Body scroll is locked while open. The default settings use native checkboxes with role="switch" and associated labels. Under prefers-reduced-motion the panel only fades.

Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.

Source

components/ui/drawer.tsx
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/drawer.module.css";7 8export interface DrawerProps {9  /** Controlled open state */10  open?: boolean;11  /** Initial open state when uncontrolled */12  defaultOpen?: boolean;13  /** Called whenever the drawer requests to open or close */14  onOpenChange?: (open: boolean) => void;15  /** Edge of the viewport the drawer slides in from */16  side?: "left" | "right" | "top" | "bottom";17  /** Panel width (left/right) or height (top/bottom) in pixels */18  size?: number;19  /** Label of the trigger button */20  triggerLabel?: string;21  /** Drawer title (accessible name) */22  title?: string;23  /** Supporting description under the title (accessible description) */24  description?: string;25  /** Body content; defaults to a notification settings list */26  children?: React.ReactNode;27  /** Label of the primary action */28  confirmLabel?: string;29  /** Label of the secondary (cancel) action */30  cancelLabel?: string;31  /** Called when the primary action is pressed (the drawer then closes) */32  onConfirm?: () => void;33  /** Accent color for the primary action, switches and focus highlights */34  accentColor?: string;35  /** Backdrop blur in pixels */36  backdropBlur?: number;37  /** Close when clicking the backdrop */38  closeOnOutsideClick?: boolean;39  /** Show the X close button in the corner */40  showCloseButton?: boolean;41  /** Optional className merged onto the root */42  className?: string;43  /** Optional inline styles merged onto the root */44  style?: React.CSSProperties;45}46 47const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];48const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';49const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;50 51function readThemeVars(el: Element | null): Record<string, string> {52  const out: Record<string, string> = {};53  if (!el) return out;54  const cs = getComputedStyle(el);55  for (const t of THEME_TOKENS) {56    const v = cs.getPropertyValue(t).trim();57    if (v) out[t] = v;58  }59  return out;60}61 62const SETTINGS = [63  { label: "Product updates", hint: "New features and improvements", on: true },64  { label: "Security alerts", hint: "Sign-ins from new devices", on: true },65  { label: "Weekly digest", hint: "A summary of your team's activity", on: false },66  { label: "Mentions", hint: "When someone @mentions you", on: true },67];68 69function DefaultBody({ id }: { id: string }) {70  return (71    <ul className={styles.list}>72      {SETTINGS.map((s, i) => (73        <li key={s.label} className={styles.row}>74          <label className={styles.rowLabel} htmlFor={`${id}-s${i}`}>75            <span className={styles.rowTitle}>{s.label}</span>76            <span className={styles.rowHint}>{s.hint}</span>77          </label>78          <input id={`${id}-s${i}`} type="checkbox" role="switch" className={styles.switch} defaultChecked={s.on} />79        </li>80      ))}81    </ul>82  );83}84 85export function Drawer({86  open: openProp,87  defaultOpen = false,88  onOpenChange,89  side = "right",90  size = 380,91  triggerLabel = "Open settings",92  title = "Notifications",93  description = "Choose what you want to hear about. Changes apply to all your devices.",94  children,95  confirmLabel = "Save preferences",96  cancelLabel = "Cancel",97  onConfirm,98  accentColor = "#6366f1",99  backdropBlur = 4,100  closeOnOutsideClick = true,101  showCloseButton = true,102  className = "",103  style,104}: DrawerProps) {105  const reduce = useReducedMotion();106  const [inner, setInner] = React.useState(defaultOpen);107  const open = openProp !== undefined ? openProp : inner;108  const setOpen = React.useCallback(109    (v: boolean) => {110      if (openProp === undefined) setInner(v);111      onOpenChange?.(v);112    },113    [openProp, onOpenChange],114  );115  const setOpenRef = React.useRef(setOpen);116  setOpenRef.current = setOpen;117  const [mounted, setMounted] = React.useState(false);118  const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});119  const rootRef = React.useRef<HTMLDivElement>(null);120  const triggerRef = React.useRef<HTMLButtonElement>(null);121  const panelRef = React.useRef<HTMLDivElement>(null);122  const wasOpen = React.useRef(false);123  const id = React.useId();124 125  React.useEffect(() => setMounted(true), []);126  useIsoLayoutEffect(() => {127    if (open) setThemeVars(readThemeVars(rootRef.current));128  }, [open]);129 130  React.useEffect(() => {131    if (!open) {132      if (wasOpen.current) triggerRef.current?.focus();133      wasOpen.current = false;134      return;135    }136    wasOpen.current = true;137    const body = document.body;138    const prev = [body.style.overflow, body.style.paddingRight];139    const gap = window.innerWidth - document.documentElement.clientWidth;140    body.style.overflow = "hidden";141    if (gap > 0) body.style.paddingRight = `${gap}px`;142    const onKey = (e: KeyboardEvent) => {143      if (e.key === "Escape") {144        e.preventDefault();145        setOpenRef.current(false);146      }147    };148    document.addEventListener("keydown", onKey);149    const raf = requestAnimationFrame(() => {150      const panel = panelRef.current;151      if (panel) (panel.querySelector<HTMLElement>(FOCUSABLE) ?? panel).focus();152    });153    return () => {154      cancelAnimationFrame(raf);155      document.removeEventListener("keydown", onKey);156      body.style.overflow = prev[0];157      body.style.paddingRight = prev[1];158    };159  }, [open]);160 161  const trapFocus = (e: React.KeyboardEvent<HTMLDivElement>) => {162    if (e.key !== "Tab" || !panelRef.current) return;163    const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));164    if (nodes.length === 0) return e.preventDefault();165    const first = nodes[0];166    const last = nodes[nodes.length - 1];167    if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {168      e.preventDefault();169      last.focus();170    } else if (!e.shiftKey && document.activeElement === last) {171      e.preventDefault();172      first.focus();173    }174  };175 176  const horizontal = side === "left" || side === "right";177  const hidden = horizontal ? { x: side === "left" ? "-100%" : "100%" } : { y: side === "top" ? "-100%" : "100%" };178  const shown = horizontal ? { x: "0%" } : { y: "0%" };179 180  return (181    <div ref={rootRef} className={`${styles.root} ${className}`.trim()} style={{ "--mf-drw-accent": accentColor, ...style } as React.CSSProperties}>182      <button ref={triggerRef} type="button" aria-haspopup="dialog" aria-expanded={open} className={styles.trigger} onClick={() => setOpen(true)}>183        {triggerLabel}184      </button>185      {mounted &&186        createPortal(187          <AnimatePresence>188            {open && (189              <div className={styles.layer} style={{ ...themeVars, "--mf-drw-accent": accentColor, "--mf-drw-blur": `${Math.max(0, backdropBlur)}px`, "--mf-drw-size": `${size}px` } as React.CSSProperties}>190                <motion.div191                  aria-hidden="true"192                  className={styles.backdrop}193                  initial={{ opacity: 0 }}194                  animate={{ opacity: 1 }}195                  exit={{ opacity: 0 }}196                  transition={{ duration: reduce ? 0.1 : 0.25 }}197                  onPointerDown={() => closeOnOutsideClick && setOpen(false)}198                />199                <motion.div200                  ref={panelRef}201                  role="dialog"202                  aria-modal="true"203                  aria-labelledby={`${id}-title`}204                  aria-describedby={description ? `${id}-desc` : undefined}205                  tabIndex={-1}206                  data-side={side}207                  className={styles.panel}208                  onKeyDown={trapFocus}209                  initial={reduce ? { opacity: 0 } : hidden}210                  animate={reduce ? { opacity: 1 } : shown}211                  exit={reduce ? { opacity: 0 } : hidden}212                  transition={reduce ? { duration: 0.1 } : { type: "spring", stiffness: 380, damping: 38, mass: 0.9 }}213                >214                  <div className={styles.header}>215                    <h2 id={`${id}-title`} className={styles.title}>216                      {title}217                    </h2>218                    {description && (219                      <p id={`${id}-desc`} className={styles.description}>220                        {description}221                      </p>222                    )}223                  </div>224                  <div className={styles.body}>{children ?? <DefaultBody id={id} />}</div>225                  <div className={styles.footer}>226                    <button type="button" className={styles.secondary} onClick={() => setOpen(false)}>227                      {cancelLabel}228                    </button>229                    <button230                      type="button"231                      className={styles.primary}232                      onClick={() => {233                        onConfirm?.();234                        setOpen(false);235                      }}236                    >237                      {confirmLabel}238                    </button>239                  </div>240                  {showCloseButton && (241                    <button type="button" aria-label="Close" className={styles.close} onClick={() => setOpen(false)}>242                      <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">243                        <path d="M18 6 6 18M6 6l12 12" />244                      </svg>245                    </button>246                  )}247                </motion.div>248              </div>249            )}250          </AnimatePresence>,251          document.body,252        )}253    </div>254  );255}