Building a Mobile-Perfect React Bottom Sheet in React: UX, A11y, and Performance

Build a mobile‑ready React bottom sheet with gestures, snap points, accessibility, and performance tips—complete with minimal and Framer Motion code.

ASOasis
8 min read
Building a Mobile-Perfect React Bottom Sheet in React: UX, A11y, and Performance

Image used for representation purposes only.

Why bottom sheets work well on mobile

Bottom sheets let users peek at content, make a quick choice, or complete a small task without losing context. They’re space‑efficient, thumb‑reachable, and familiar from native apps (Maps, Music, Wallet). In React, you can implement a bottom sheet that feels native by combining focus management, touch gestures, and GPU‑accelerated animations.

This guide covers when to use a bottom sheet, the essential building blocks, accessibility, implementation options (from scratch and with Framer Motion), performance tips, and a production‑ready checklist.

When to use a bottom sheet

  • Quick actions: share, sort, filter, pickers.
  • Contextual details: item previews, location info, receipts.
  • Light workflows: confirming an action, entering a small amount of data.

Avoid bottom sheets for multi‑step or content‑heavy flows—use a full‑screen modal or route instead.

Anatomy of a bottom sheet

  • Backdrop: a semi‑transparent overlay that dims background and captures taps to dismiss.
  • Container: fixed to the viewport bottom; animates via transform: translateY().
  • Handle: a visual affordance for drag; aim for at least 44×4 px with 16 px margins.
  • Content area: scrollable; supports snap points (e.g., 25%, 50%, 100%).
  • Close targets: tapping the backdrop, dragging down past a threshold, or pressing Escape.

Accessibility essentials

  • Role and labeling: role=“dialog” with aria-modal=“true”. Provide aria-labelledby and/or aria-describedby.
  • Focus management: move focus into the sheet on open; trap focus; restore focus to the opener on close.
  • Background inertness: set inert or aria-hidden on the app root when open.
  • Keyboard support: Escape closes; Tab cycles within the sheet. Respect prefers-reduced-motion.
  • Announcements: use aria-live for async content inside the sheet if state changes after open.

Layout and CSS fundamentals

  • Use position: fixed; left: 0; right: 0; bottom: 0; and translateY to animate.
  • Prefer dynamic viewport units (dvh) for mobile browser UI chrome changes.
  • Respect safe areas on iOS with env(safe-area-inset-bottom).
  • Prevent background scroll with body overflow: hidden and overscroll-behavior.
  • Use will-change: transform on the sheet for smoother animation.

Example CSS foundation:

:root {
  --sheet-radius: 16px;
  --sheet-shadow: 0 -8px 24px rgba(0,0,0,0.22);
  --backdrop: rgba(0,0,0,0.4);
}

html, body { height: 100%; }
body.sheet-open { overflow: hidden; }

.BottomSheet-backdrop {
  position: fixed; inset: 0; background: var(--backdrop);
  opacity: 0; transition: opacity 200ms ease;
}
.BottomSheet-backdrop[data-open="true"] { opacity: 1; }

.BottomSheet {
  position: fixed; left: 0; right: 0; bottom: 0;
  max-height: 100dvh; /* dynamic viewport */
  background: var(--surface, #111);
  color: var(--on-surface, #fff);
  border-top-left-radius: var(--sheet-radius);
  border-top-right-radius: var(--sheet-radius);
  box-shadow: var(--sheet-shadow);
  transform: translateY(100%);
  will-change: transform;
  touch-action: none; /* we’ll handle pan */
}

.BottomSheet[data-open="true"] { transform: translateY(0%); }

.BottomSheet-handle {
  width: 44px; height: 4px; border-radius: 999px;
  background: rgba(255,255,255,0.35);
  margin: 8px auto; cursor: grab;
}

.BottomSheet-content {
  padding: 8px 16px calc(16px + env(safe-area-inset-bottom));
  max-height: calc(100dvh - 24px); /* account for handle and radius */
  overflow: auto; overscroll-behavior: contain;
}

A minimal, accessible React bottom sheet (no external animation lib)

This version provides open/close, focus trapping, backdrop click, and Escape to close. It uses CSS transitions and pointer events for a simple swipe‑to‑close.

import React, {useEffect, useRef, useState} from 'react';

type BottomSheetProps = {
  open: boolean;
  onClose: () => void;
  labelledBy?: string; // id of title element
  children: React.ReactNode;
};

export function BottomSheet({ open, onClose, labelledBy, children }: BottomSheetProps) {
  const sheetRef = useRef<HTMLDivElement>(null);
  const lastFocused = useRef<HTMLElement | null>(null);
  const [dragY, setDragY] = useState(0); // pixels; positive is downward
  const startY = useRef<number | null>(null);
  const raf = useRef<number | null>(null);

  // Body scroll lock and focus management
  useEffect(() => {
    const body = document.body;
    if (open) {
      body.classList.add('sheet-open');
      lastFocused.current = document.activeElement as HTMLElement;
      // Focus first focusable inside
      setTimeout(() => sheetRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      )?.focus(), 0);
    } else {
      body.classList.remove('sheet-open');
      lastFocused.current?.focus?.();
      setDragY(0);
    }
    return () => body.classList.remove('sheet-open');
  }, [open]);

  // Escape to close
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
      if (e.key === 'Tab') trapFocus(e);
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, onClose]);

  // Simple focus trap
  const trapFocus = (e: KeyboardEvent) => {
    const focusables = sheetRef.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 === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
  };

  // Drag to close (downward only)
  useEffect(() => {
    const el = sheetRef.current; if (!el) return;

    const onPointerDown = (e: PointerEvent) => {
      if ((e.target as HTMLElement).closest('.BottomSheet-handle') || e.clientY > window.innerHeight - el.offsetHeight + 60) {
        el.setPointerCapture(e.pointerId);
        startY.current = e.clientY; setDragY(0);
      }
    };
    const onPointerMove = (e: PointerEvent) => {
      if (startY.current == null) return;
      const dy = Math.max(0, e.clientY - startY.current);
      setDragY(dy);
      if (raf.current) cancelAnimationFrame(raf.current);
      raf.current = requestAnimationFrame(() => {
        el.style.transform = `translateY(${dy}px)`;
      });
    };
    const onPointerUp = () => {
      if (startY.current == null) return;
      const threshold = Math.min(160, el.offsetHeight * 0.25);
      const shouldClose = dragY > threshold;
      el.style.transform = '';
      setDragY(0); startY.current = null;
      if (shouldClose) onClose();
    };

    el.addEventListener('pointerdown', onPointerDown);
    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
    return () => {
      el.removeEventListener('pointerdown', onPointerDown);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
    };
  }, [dragY, onClose]);

  return (
    <>
      <div
        className="BottomSheet-backdrop"
        data-open={open}
        aria-hidden={!open}
        onClick={onClose}
      />
      <div
        ref={sheetRef}
        className="BottomSheet"
        data-open={open}
        role="dialog"
        aria-modal="true"
        aria-labelledby={labelledBy}
        style={{ transition: startY.current ? 'none' : 'transform 240ms cubic-bezier(.2,.8,.2,1)' }}
      >
        <div className="BottomSheet-handle" aria-hidden="true" />
        <div className="BottomSheet-content">{children}</div>
      </div>
    </>
  );
}

Usage example:

function Demo() {
  const [open, setOpen] = React.useState(false);
  return (
    <div>
      <button onClick={() => setOpen(true)}>Open sheet</button>
      <BottomSheet open={open} onClose={() => setOpen(false)} labelledBy="sheet-title">
        <h2 id="sheet-title">Filters</h2>
        {/* Your form or list */}
      </BottomSheet>
    </div>
  );
}

Gestures, snap points, and physics (with Framer Motion)

If you want natural drag, momentum, and snap points, a motion library simplifies things. Here’s a compact Framer Motion approach with three snap points.

import { motion, useMotionValue, animate } from 'framer-motion';

const snaps = [0.25, 0.5, 1]; // of viewport height

function SheetFM({ open, onClose, children }: { open: boolean; onClose: () => void; children: React.ReactNode }) {
  const vh = () => window.innerHeight;
  const y = useMotionValue(open ? 0 : vh());

  React.useEffect(() => {
    const dest = open ? 0 : vh();
    const controls = animate(y, dest, { type: 'spring', damping: 32, stiffness: 320 });
    return controls.stop;
  }, [open]);

  const onDragEnd = (_: any, info: { offset: { y: number }, velocity: { y: number } }) => {
    const current = y.get();
    const projected = current + info.velocity.y * 0.2; // simple projection
    const targets = snaps.map(s => vh() * (1 - s)); // 0% (open) to 75% hidden
    targets.push(vh()); // fully closed

    // Choose nearest target
    const nearest = targets.reduce((a, b) => Math.abs(b - projected) < Math.abs(a - projected) ? b : a, targets[0]);
    if (nearest >= vh() * 0.95) onClose();
    animate(y, nearest, { type: 'spring', damping: 32, stiffness: 320 });
  };

  return (
    <>
      <div className="BottomSheet-backdrop" data-open={open} onClick={onClose} />
      <motion.div
        className="BottomSheet"
        role="dialog" aria-modal="true"
        style={{ y }}
        drag="y" dragConstraints={{ top: 0, bottom: 0 }}
        onDragEnd={onDragEnd}
      >
        <div className="BottomSheet-handle" />
        <div className="BottomSheet-content">{children}</div>
      </motion.div>
    </>
  );
}

Notes:

  • y is in pixels; 0 means fully open. We animate to snap points by converting fractions of viewport height to pixel offsets.
  • You can change the snap logic to prefer expanding if the drag direction is upward and velocity is strong.
  • Always test with prefers-reduced-motion and disable springy transitions when requested.

Performance playbook

  • Composite‑only animations: animate transform and opacity. Avoid animating height/top.
  • Reduce paints: prefer shadows with small blur radii and avoid large background blurs.
  • Virtualize long lists in the sheet; lazy‑render heavy components after open.
  • Passive listeners: addEventListener(’touchmove’, …, { passive: true }) for scrollable content; use non‑passive only for the drag surface.
  • Avoid forced reflows: batch reads and writes; in libraries, rely on motion values that don’t layout‑thrash.
  • Use CSS contain: content on complex subtrees to isolate layout/paint costs where possible.

Mobile nuances and compatibility

  • Dynamic viewport units: prefer 100dvh over 100vh to handle browser chrome resizes on mobile.
  • iOS overscroll: use overscroll-behavior to prevent background scroll chaining.
  • Safe areas: pad the bottom content with env(safe-area-inset-bottom) for devices with home indicators.
  • Input focus: when forms are inside the sheet, account for the on‑screen keyboard. Consider scroll-padding-bottom to keep inputs visible.
  • Z‑index hygiene: render the sheet in a portal at the end of body to avoid stacking issues.

Example portal wrapper:

import { createPortal } from 'react-dom';

function Portal({ children }: { children: React.ReactNode }) {
  const el = React.useMemo(() => document.createElement('div'), []);
  useEffect(() => { document.body.appendChild(el); return () => { document.body.removeChild(el); }; }, [el]);
  return createPortal(children, el);
}

Testing and QA checklist

  • Keyboard: Tab cycles within the sheet; Escape closes; focus restored to opener.
  • Screen readers: dialog is announced with a clear title; background is inert.
  • Touch: drag handle responds; backdrop tap closes; scroll inside content does not scroll the page beneath.
  • Edge cases: orientation changes; very tall and very short screens; long lists; form inputs with the keyboard shown.
  • E2E: write Playwright tests to drag past thresholds and verify snap behavior. Include accessibility checks (e.g., axe) in CI.

Build vs. buy: libraries to consider

If you prefer a headless or batteries‑included solution, explore:

  • Headless + gestures: combine a headless dialog (for a11y) with a motion library for drag/snap.
  • Purpose‑built bottom sheet packages: some offer inertia, nested sheets, and RTL support out of the box.

Even with a library, validate a11y, scroll locking, and performance in your app’s context.

Production hardening checklist

  • aria-modal, aria-labelledby, programmatic focus on open and restore on close.
  • Body scroll lock, background inert/aria-hidden.
  • prefers-reduced-motion respected.
  • Portal rendering with clear stacking context.
  • Snap points tested across screen sizes and keyboard open states.
  • Robust gesture thresholds and velocity handling.
  • Fast path for static (no‑gesture) devices like desktop.
  • Unit/E2E coverage for open/close, drag, and focus trap.

Conclusion

A great bottom sheet feels native: it opens quickly, drags smoothly, snaps intuitively, and remains fully accessible. Start with the minimal component for correctness, then layer in gestures and physics. Profile on real devices, respect user preferences, and keep the animation pipeline on the GPU. With these patterns, your React bottom sheet will be both delightful and dependable on mobile.

Related Posts