All patterns

Bottom Sheet Present

A sheet rises from the bottom edge over a dimming backdrop and settles once.

navigationpremiumcalminteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

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.

335 lines · react + motion only
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.

Payment method pickerShare optionsFilter panelMobile action menu

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Delete this project?Its 21 files and every share link stop working. This cannot be undone.
    CancelDelete
    Modal sheet

    System sheets rise over a dimmed screen and land without a rebound.

Related patterns