Bottom Sheet Present
A sheet rises from the bottom edge over a dimming backdrop and settles once.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Bottom Sheet Present
*
* A sheet that rises from the bottom edge over a dimming backdrop and
* settles once. Tap the backdrop or press Escape to send it back.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The screen behind is mixed from the inherited text color and the sheet
* follows the host app's color scheme, so both land correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `title`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type BottomSheetPresentProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Sheet heading, also its accessible name. */
title?: string;
/** Notified whenever the sheet opens or closes. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
backdropFade: number;
exitDuration: number;
};
// Quality rule: the sheet is the largest surface in the library, and a
// large surface makes overshoot look like a mistake rather than a flourish.
// Every spring is at or above a 0.8 damping ratio — it settles once, and
// only the sheet moves. Nothing behind it scales, because scaling the page
// would scale its text.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Lands flat, no settle at all. For sheets that open on every other tap.
subtle: {
spring: { type: "spring", stiffness: 560, damping: 47 },
backdropFade: 0.12,
exitDuration: 0.14,
},
// One soft settle as it arrives — the weight that makes a sheet feel
// like an object. All-purpose.
default: {
spring: { type: "spring", stiffness: 340, damping: 32 },
backdropFade: 0.22,
exitDuration: 0.22,
},
// Rises more slowly and with more weight behind it — the sheet reads
// as a heavier object arriving, not a livelier one. For sheets that
// are the whole point of the screen.
playful: {
spring: { type: "spring", stiffness: 220, damping: 26 },
backdropFade: 0.34,
exitDuration: 0.32,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color in scope —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface, border or fill that is correctly
* toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const METHODS = [
"Visa ending 4242",
"Mastercard ending 8130",
"Amex ending 1007",
] as const;
export default function BottomSheetPresent({
variant = "default",
title = "Payment method",
onOpenChange,
}: BottomSheetPresentProps) {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const setSheet = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
onOpenChange?.(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
// Reduced motion: the sheet still arrives over a dimmed page, it just
// stops travelling to get there — a full-height rise is exactly the
// movement this setting is asking us to drop.
const sheetMotion = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.12 } },
transition: { duration: 0.16, ease: "easeOut" as const },
}
: {
initial: { y: "100%" },
animate: { y: "0%" },
// Dismissal is not worth watching: a plain ease-in, quicker than
// the rise, keeps the sheet from feeling reluctant to leave.
exit: {
y: "100%",
transition: { duration: cfg.exitDuration, ease: "easeIn" as const },
},
transition: cfg.spring,
};
return (
<div
style={{
position: "relative",
width: 300,
height: 420,
display: "flex",
flexDirection: "column",
borderRadius: 26,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 18px 44px rgba(0,0,0,0.2)",
// The sheet is positioned against this box rather than the
// viewport, so the pattern can be dropped into a preview or an
// embedded card. Swap `absolute` for `fixed` on the backdrop and
// sheet to present over a whole app.
overflow: "hidden",
}}
>
<div style={{ flex: 1, padding: "22px 20px" }}>
<div style={{ fontSize: 12.5, opacity: 0.5 }}>Order total</div>
<div style={{ fontSize: 27, fontWeight: 650, marginTop: 2 }}>$248.00</div>
<div
style={{
marginTop: 18,
paddingTop: 14,
borderTop: `1px solid ${tone(12)}`,
fontSize: 12.5,
opacity: 0.5,
}}
>
Paying with
</div>
<div style={{ fontSize: 14, fontWeight: 600, marginTop: 4 }}>
{METHODS[selected]}
</div>
</div>
<div style={{ padding: "0 20px 22px" }}>
<button
type="button"
onClick={() => setSheet(true)}
style={{
width: "100%",
padding: "12px 14px",
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 12,
border: 0,
background: ACCENT,
color: "#ffffff",
cursor: "pointer",
}}
>
Change payment method
</button>
</div>
{/* Two siblings rather than a fragment: AnimatePresence only tracks
its direct children, and a fragment would hide both from it and
skip the exit. */}
<AnimatePresence>
{open && (
<motion.div
key="backdrop"
aria-hidden
onClick={() => setSheet(false)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: cfg.backdropFade, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
// A scrim darkens in both themes — light or dark, the page
// behind a sheet recedes — so this one stays literal.
background: "rgba(0,0,0,0.5)",
cursor: "pointer",
}}
/>
)}
{open && (
<motion.div
key="sheet"
role="dialog"
aria-modal="true"
aria-label={title}
{...sheetMotion}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
padding: "10px 16px 18px",
borderRadius: "22px 22px 0 0",
// The sheet is the one surface here that cannot be
// translucent: it sits on top of the scrim, and a
// see-through panel would just read as more scrim.
// `Canvas`/`CanvasText` are the CSS system colors for page
// background and page text — they follow the host app's
// color scheme, so the sheet lands light in a light app and
// dark in a dark one, and the pair is legible either way.
// Everything inside the sheet then mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
borderTop: `1px solid ${tone(14)}`,
boxShadow: "0 -14px 34px rgba(0,0,0,0.3)",
}}
>
<div
aria-hidden
style={{
width: 36,
height: 4,
margin: "0 auto 14px",
borderRadius: 2,
background: tone(26),
}}
/>
<div style={{ fontSize: 15, fontWeight: 650, padding: "0 2px 6px" }}>
{title}
</div>
{METHODS.map((method, index) => (
<button
key={method}
type="button"
onClick={() => setSelected(index)}
aria-pressed={index === selected}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 11,
padding: "11px 10px",
background: "none",
border: 0,
borderRadius: 10,
color: "inherit",
fontSize: 13.5,
fontFamily: "inherit",
textAlign: "left",
cursor: "pointer",
}}
>
<svg width="21" height="14" viewBox="0 0 24 16" fill="none" aria-hidden>
<rect
x="0.7"
y="0.7"
width="22.6"
height="14.6"
rx="3"
stroke="currentColor"
strokeWidth="1.3"
opacity="0.45"
/>
<path
d="M1 5.4h22"
stroke="currentColor"
strokeWidth="1.3"
opacity="0.45"
/>
</svg>
<span style={{ flex: 1 }}>{method}</span>
<motion.span
aria-hidden
initial={false}
animate={{ opacity: index === selected ? 1 : 0 }}
transition={{ duration: 0.14, ease: "easeOut" }}
style={{ display: "grid", placeItems: "center" }}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path
d="M3.4 8.4 6.6 11.6 12.7 5"
stroke={ACCENT}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</button>
))}
<button
type="button"
onClick={() => setSheet(false)}
style={{
width: "100%",
marginTop: 12,
padding: "11px 14px",
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
borderRadius: 11,
border: `1px solid ${tone(16)}`,
background: tone(7),
color: "inherit",
cursor: "pointer",
}}
>
Done
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
Secondary choices that shouldn't cost a screen change: pick a payment method, share a link, filter a list. The backdrop dims as the sheet travels up from the edge it came from, which is what tells the user the page underneath is still there and still theirs to return to. Everything rides on the landing — this is the largest surface in the library, and a large surface that bounces reads as a mistake rather than a flourish, so the spring settles once and the page behind it never scales. Dismissal is deliberately quicker than the entrance, and Escape and the backdrop both work.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Modal sheet
System sheets rise over a dimmed screen and land without a rebound.
Related patterns
- Drawer Slide InA side drawer travels in over the page, and the page eases back a little to hand it the foreground.
- Modal Scale InA dialog grows the last few percent into place behind a darkening scrim, and leaves the same way.
- Mobile Menu FullscreenA hamburger becomes a close glyph as a full-screen menu wipes in with staggered links.