UIOverlays and Menusmotion/react

BottomSheet

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 props
0
2
640

Installation

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

npx motionfoundry add bottom-sheet

Writes 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.

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

Props

PropTypeDefault
open

Controlled open state.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called whenever the sheet requests to open or close.

(open: boolean) => voidundefined
snapPoints

Snap heights as fractions of the viewport height (clamped to 0.1 to 1, sorted ascending).

number[][0.45, 0.88]
defaultSnap

Index of the snap point the sheet opens at.

number0
onSnapChange

Called when the sheet moves to a different snap index.

(index: number) => voidundefined
triggerLabel

Label of the trigger button.

string'Share project'
title

Sheet title, used as the accessible name.

string'Share project'
description

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

string'Anyone with the link can view. Drag the handle or use the arrow keys to resize.'
children

Sheet content; defaults to share actions and a people list.

React.ReactNodeshare sheet
accentColor

Accent for the focused handle, action hovers and avatars.

string'#6366f1'
backdropBlur

Backdrop blur radius in pixels; the backdrop fades with the drag position.

number2
dismissible

Allow closing by flicking/dragging below the lowest snap or clicking the backdrop.

booleantrue
maxWidth

Maximum sheet width in pixels; centered on wide screens.

number640
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/bottom-sheet.tsx
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}