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 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add drawerWrites 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.
1import { Drawer } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Drawer />6 );7}Props
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
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}