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.
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
Build a Smooth, Accessible React Marquee (Scrolling Text) Component
Build an accessible, performant React marquee with CSS transforms, dynamic speed, pause on hover, gradients, and reduced-motion support. Full code inside.
Building an Accessible, Reusable React Progress Indicator (Linear and Circular)
Build an accessible, themeable React progress indicator (linear and circular) with TypeScript, complete code, a11y, performance tips, theming, and tests.
Building an Accessible React Toggle Switch (The Right Way)
Build an accessible React toggle switch: semantics-first component with native checkbox, keyboard support, forms, RTL, reduced motion, and tests.