A mobile-style modal sheet that springs up from the bottom edge with a drag handle and snap points. Drag (with velocity projection) to resize between snaps or flick down to dismiss; the handle is also a keyboard-operable button.
Preview
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add bottom-sheetWrites components/ui/bottom-sheet.tsx and components/styles/bottom-sheet.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { BottomSheet } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <BottomSheet />6 );7}Props
Accessibility
role="dialog" with aria-modal, aria-labelledby (title) and aria-describedby (description); the trigger exposes aria-haspopup="dialog" and aria-expanded. The drag handle is a real button labelled "Expand sheet" / "Collapse sheet": Enter/Space toggles between the lowest and highest snap, ArrowUp/ArrowDown step between snaps (ArrowDown at the lowest snap closes when dismissible), Home/End jump to the extremes. Focus is trapped inside while open, Escape closes, focus returns to the trigger and body scroll is locked. Under prefers-reduced-motion the sheet snaps without springing.
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 { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";6import styles from "../styles/bottom-sheet.module.css";7 8export interface BottomSheetProps {9 /** Controlled open state */10 open?: boolean;11 /** Initial open state when uncontrolled */12 defaultOpen?: boolean;13 /** Called whenever the sheet requests to open or close */14 onOpenChange?: (open: boolean) => void;15 /** Snap heights as fractions of the viewport height (0.1 to 1), e.g. [0.45, 0.9] */16 snapPoints?: number[];17 /** Index of the snap point the sheet opens at */18 defaultSnap?: number;19 /** Called after the sheet settles on a new snap index */20 onSnapChange?: (index: number) => void;21 /** Label of the trigger button */22 triggerLabel?: string;23 /** Sheet title (accessible name) */24 title?: string;25 /** Supporting description under the title */26 description?: string;27 /** Sheet content; defaults to a share sheet */28 children?: React.ReactNode;29 /** Accent color for highlights and focus */30 accentColor?: string;31 /** Backdrop blur in pixels */32 backdropBlur?: number;33 /** Allow closing by dragging down past the lowest snap point or clicking the backdrop */34 dismissible?: boolean;35 /** Maximum sheet width in pixels (centered on wide screens) */36 maxWidth?: number;37 /** Optional className merged onto the root */38 className?: string;39 /** Optional inline styles merged onto the root */40 style?: React.CSSProperties;41}42 43const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];44const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';45const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;46const SPRING = { type: "spring", stiffness: 420, damping: 42, mass: 0.9 } as const;47 48function readThemeVars(el: Element | null): Record<string, string> {49 const out: Record<string, string> = {};50 if (!el) return out;51 const cs = getComputedStyle(el);52 for (const t of THEME_TOKENS) {53 const v = cs.getPropertyValue(t).trim();54 if (v) out[t] = v;55 }56 return out;57}58 59const ACTIONS = [60 { label: "Copy link", d: "M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" },61 { label: "Email", d: "M3 6h18v12H3zM3 7l9 6 9-6" },62 { label: "Message", d: "M21 12a8 8 0 0 1-11.6 7.1L4 20l1-4.6A8 8 0 1 1 21 12z" },63 { label: "Export", d: "M12 3v12M7 8l5-5 5 5M5 15v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4" },64];65const PEOPLE = [66 { name: "Olivia Martin", mail: "olivia@acme.dev", role: "Owner" },67 { name: "Jackson Lee", mail: "jackson@acme.dev", role: "Can edit" },68 { name: "Isabella Nguyen", mail: "isabella@acme.dev", role: "Can view" },69 { name: "William Kim", mail: "will@acme.dev", role: "Can view" },70];71 72function DefaultContent() {73 return (74 <>75 <div className={styles.actions}>76 {ACTIONS.map((a) => (77 <button key={a.label} type="button" className={styles.action}>78 <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">79 <path d={a.d} />80 </svg>81 {a.label}82 </button>83 ))}84 </div>85 <p className={styles.section}>People with access</p>86 <ul className={styles.people}>87 {PEOPLE.map((p) => (88 <li key={p.mail} className={styles.person}>89 <span aria-hidden="true" className={styles.avatar}>90 {p.name.split(" ").map((s) => s[0]).join("")}91 </span>92 <span className={styles.who}>93 <span className={styles.name}>{p.name}</span>94 <span className={styles.mail}>{p.mail}</span>95 </span>96 <span className={styles.role}>{p.role}</span>97 </li>98 ))}99 </ul>100 </>101 );102}103 104export function BottomSheet({105 open: openProp,106 defaultOpen = false,107 onOpenChange,108 snapPoints = [0.45, 0.88],109 defaultSnap = 0,110 onSnapChange,111 triggerLabel = "Share project",112 title = "Share project",113 description = "Anyone with the link can view. Drag the handle or use the arrow keys to resize.",114 children,115 accentColor = "#6366f1",116 backdropBlur = 2,117 dismissible = true,118 maxWidth = 640,119 className = "",120 style,121}: BottomSheetProps) {122 const reduce = useReducedMotion();123 const [inner, setInner] = React.useState(defaultOpen);124 const open = openProp !== undefined ? openProp : inner;125 const setOpen = React.useCallback(126 (v: boolean) => {127 if (openProp === undefined) setInner(v);128 onOpenChange?.(v);129 },130 [openProp, onOpenChange],131 );132 const snaps = React.useMemo(() => {133 const s = snapPoints.map((p) => Math.min(1, Math.max(0.1, p))).sort((a, b) => a - b);134 return s.length ? s : [0.5];135 }, [snapPoints]);136 const last = snaps.length - 1;137 const startSnap = Math.min(last, Math.max(0, Math.round(defaultSnap)));138 const [present, setPresent] = React.useState(false);139 const [snap, setSnap] = React.useState(startSnap);140 const [vh, setVh] = React.useState(800);141 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});142 const rootRef = React.useRef<HTMLDivElement>(null);143 const triggerRef = React.useRef<HTMLButtonElement>(null);144 const panelRef = React.useRef<HTMLDivElement>(null);145 const drag = React.useRef<{ y0: number; off0: number; ly: number; lt: number; v: number; moved: boolean } | null>(null);146 const dragged = React.useRef(false);147 const id = React.useId();148 const y = useMotionValue(4000);149 const geo = React.useRef({ low: 0, closed: 1 });150 const offsetFor = (i: number, h = vh) => (snaps[last] - snaps[i]) * h;151 geo.current = { low: offsetFor(0), closed: snaps[last] * vh + 24 };152 const backdrop = useTransform(y, (v) => 1 - Math.min(1, Math.max(0, (v - geo.current.low) / (geo.current.closed - geo.current.low))));153 const setOpenRef = React.useRef(setOpen);154 setOpenRef.current = setOpen;155 const tween = reduce ? { duration: 0 } : SPRING;156 157 useIsoLayoutEffect(() => {158 if (open) {159 const h = window.innerHeight;160 setThemeVars(readThemeVars(rootRef.current));161 setVh(h);162 setSnap(startSnap);163 setPresent(true);164 if (y.get() > snaps[last] * h) y.set(snaps[last] * h + 24);165 const ctrl = animate(y, (snaps[last] - snaps[startSnap]) * h, tween);166 return () => ctrl.stop();167 }168 if (!present) return;169 const ctrl = animate(y, geo.current.closed, { ...(reduce ? { duration: 0 } : { duration: 0.24, ease: [0.32, 0, 0.67, 0] }), onComplete: () => setPresent(false) });170 return () => ctrl.stop();171 }, [open]);172 173 const goTo = (i: number) => {174 const k = Math.min(last, Math.max(0, i));175 if (k !== snap) onSnapChange?.(k);176 setSnap(k);177 animate(y, offsetFor(k), tween);178 };179 180 // Scroll lock, Escape, resize, focus in / restore.181 React.useEffect(() => {182 if (!open) return;183 const body = document.body;184 const prev = body.style.overflow;185 body.style.overflow = "hidden";186 const onKey = (e: KeyboardEvent) => {187 if (e.key === "Escape") {188 e.preventDefault();189 setOpenRef.current(false);190 }191 };192 const onResize = () => setVh(window.innerHeight);193 document.addEventListener("keydown", onKey);194 window.addEventListener("resize", onResize);195 const raf = requestAnimationFrame(() => (panelRef.current?.querySelector<HTMLElement>(FOCUSABLE) ?? panelRef.current)?.focus());196 const trigger = triggerRef.current;197 return () => {198 cancelAnimationFrame(raf);199 document.removeEventListener("keydown", onKey);200 window.removeEventListener("resize", onResize);201 body.style.overflow = prev;202 trigger?.focus();203 };204 }, [open]);205 206 React.useEffect(() => {207 if (open && !drag.current) y.set(offsetFor(snap));208 }, [vh]);209 210 const trapFocus = (e: React.KeyboardEvent<HTMLDivElement>) => {211 if (e.key !== "Tab" || !panelRef.current) return;212 const nodes = Array.from(panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE));213 if (!nodes.length) return e.preventDefault();214 const [first, end] = [nodes[0], nodes[nodes.length - 1]];215 if (e.shiftKey && (document.activeElement === first || document.activeElement === panelRef.current)) {216 e.preventDefault();217 end.focus();218 } else if (!e.shiftKey && document.activeElement === end) {219 e.preventDefault();220 first.focus();221 }222 };223 224 const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {225 if (e.button !== 0) return;226 drag.current = { y0: e.clientY, off0: y.get(), ly: e.clientY, lt: e.timeStamp, v: 0, moved: false };227 dragged.current = false;228 };229 const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {230 const d = drag.current;231 if (!d) return;232 const dy = e.clientY - d.y0;233 if (!d.moved && Math.abs(dy) > 4) {234 d.moved = dragged.current = true;235 e.currentTarget.setPointerCapture(e.pointerId);236 }237 if (!d.moved) return;238 let next = d.off0 + dy;239 if (next < 0) next *= 0.25;240 if (!dismissible && next > geo.current.low) next = geo.current.low + (next - geo.current.low) * 0.25;241 y.set(next);242 const dt = Math.max(1, e.timeStamp - d.lt);243 d.v = (e.clientY - d.ly) / dt;244 d.ly = e.clientY;245 d.lt = e.timeStamp;246 };247 const onPointerUp = () => {248 const d = drag.current;249 drag.current = null;250 if (!d || !d.moved) return;251 const projected = y.get() + d.v * 160;252 if (dismissible && projected > geo.current.low + (geo.current.closed - geo.current.low) * 0.45) return setOpen(false);253 let best = 0;254 snaps.forEach((_, i) => {255 if (Math.abs(offsetFor(i) - projected) < Math.abs(offsetFor(best) - projected)) best = i;256 });257 goTo(best);258 };259 260 const onHandleKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {261 if (e.key === "ArrowUp" || e.key === "End") {262 e.preventDefault();263 goTo(e.key === "End" ? last : snap + 1);264 } else if (e.key === "ArrowDown" || e.key === "Home") {265 e.preventDefault();266 if (e.key === "ArrowDown" && snap === 0 && dismissible) setOpen(false);267 else goTo(e.key === "Home" ? 0 : snap - 1);268 }269 };270 271 const expanded = snap === last;272 273 return (274 <div ref={rootRef} className={`${styles.root} ${className}`.trim()} style={{ "--mf-bs-accent": accentColor, ...style } as React.CSSProperties}>275 <button ref={triggerRef} type="button" aria-haspopup="dialog" aria-expanded={open} className={styles.trigger} onClick={() => setOpen(true)}>276 {triggerLabel}277 </button>278 {present &&279 createPortal(280 <div className={styles.layer} style={{ ...themeVars, "--mf-bs-accent": accentColor, "--mf-bs-blur": `${Math.max(0, backdropBlur)}px`, "--mf-bs-max-w": `${maxWidth}px` } as React.CSSProperties}>281 <motion.div aria-hidden="true" className={styles.backdrop} style={{ opacity: backdrop }} onPointerDown={() => dismissible && setOpen(false)} />282 <motion.div283 ref={panelRef}284 role="dialog"285 aria-modal="true"286 aria-labelledby={`${id}-title`}287 aria-describedby={description ? `${id}-desc` : undefined}288 tabIndex={-1}289 className={styles.panel}290 style={{ y, height: snaps[last] * vh, paddingBottom: offsetFor(snap) }}291 onKeyDown={trapFocus}292 >293 <div className={styles.grab} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>294 <button295 type="button"296 className={styles.handle}297 aria-label={expanded ? "Collapse sheet" : "Expand sheet"}298 aria-keyshortcuts="ArrowUp ArrowDown"299 onKeyDown={onHandleKey}300 onClick={() => {301 if (dragged.current) return;302 goTo(expanded ? 0 : last);303 }}304 >305 <span aria-hidden="true" className={styles.bar} />306 </button>307 <div className={styles.header}>308 <h2 id={`${id}-title`} className={styles.title}>309 {title}310 </h2>311 {description && (312 <p id={`${id}-desc`} className={styles.description}>313 {description}314 </p>315 )}316 </div>317 <button type="button" aria-label="Close" className={styles.close} onClick={() => setOpen(false)}>318 <svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">319 <path d="M18 6 6 18M6 6l12 12" />320 </svg>321 </button>322 </div>323 <div className={styles.body}>324 {children ?? <DefaultContent />}325 </div>326 </motion.div>327 </div>,328 document.body,329 )}330 </div>331 );332}