Skip to content

Draggable Bottom Sheet Modal

An iOS-style bottom sheet with peek, half and full detents: velocity-aware snapping, rubber-banding past the ends, a scrim that dims with progress, content that scrolls and then hands off to the drag, Escape and a focus trap. No dependencies.

DOM + CSS0 deps1 file · 20 KB TSXreduced-motion aware

Freeglassminimal
Preview

Props

No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";

/** How far the sheet may be pulled past its tallest snap point (it is rubber-banded), in px. */
const OVER = 96;
/** Snap points as fractions of the container's height: peek, half, full. */
const DEFAULT_SNAPS = [0.3, 0.55, 0.92];

type Theme = "light" | "dark";

const VARS: Record<Theme, CSSProperties> = {
  light: {
    "--bs-bg": "#ffffff",
    "--bs-fg": "#0b0b0d",
    "--bs-muted": "rgba(11,11,13,0.56)",
    "--bs-line": "rgba(11,11,13,0.09)",
    "--bs-grip": "rgba(11,11,13,0.22)",
    "--bs-grip-hot": "rgba(11,11,13,0.42)",
    boxShadow: "0 -1px 0 rgba(255,255,255,0.9) inset, 0 -18px 50px rgba(8,10,20,0.2), 0 -2px 8px rgba(8,10,20,0.08)",
    backgroundImage: "linear-gradient(180deg, rgba(0,0,0,0.025), rgba(0,0,0,0) 90px)",
  } as CSSProperties,
  dark: {
    "--bs-bg": "#111215",
    "--bs-fg": "#f5f5f7",
    "--bs-muted": "rgba(255,255,255,0.56)",
    "--bs-line": "rgba(255,255,255,0.09)",
    "--bs-grip": "rgba(255,255,255,0.26)",
    "--bs-grip-hot": "rgba(255,255,255,0.5)",
    boxShadow: "0 1px 0 rgba(255,255,255,0.1) inset, 0 -24px 64px rgba(0,0,0,0.6), 0 -2px 10px rgba(0,0,0,0.4)",
    backgroundImage: "linear-gradient(180deg, rgba(255,255,255,0.055), rgba(255,255,255,0) 110px)",
  } as CSSProperties,
};

type Sample = { t: number; raw: number; st: number };
type Drag = {
  id: number;
  y0: number;
  lastY: number;
  k: number;
  raw: number;
  startRaw: number;
  mode: "pending" | "active";
  inScroll: boolean;
  sheetMoved: boolean;
  hist: Sample[];
};
type Ptr = { pointerId: number; clientY: number; timeStamp: number; type: string };
type Engine = {
  v: number;
  vel: number;
  target: number;
  H: number;
  snaps: number[];
  snap: number;
  lastSnap: number;
  shownSnap: number;
  closing: boolean;
  raf: number;
  last: number;
  reduced: boolean;
  drag: Drag | null;
  swallow: boolean;
  mom: number;
  wheelAt: number;
  unlisten: (() => void) | null;
};

const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n));
/** Rubber band: 1:1-ish at first, then it resists and asymptotes ~60px past the end. */
const band = (raw: number, full: number) => (raw <= full ? raw : full + 60 * (1 - Math.exp(-((raw - full) * 0.6) / 60)));
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])';
const SNAP_NAMES = ["Peek", "Half", "Full"];

export function BottomSheet({
  open,
  onClose,
  title = "Filters",
  children,
  snapPoints = DEFAULT_SNAPS,
  defaultSnap = 1,
  onSnapChange,
  contained = false,
  theme = "light",
  dismissible = true,
  className = "",
}: {
  open: boolean;
  onClose: () => void;
  title?: string;
  children?: ReactNode;
  /** Heights the sheet rests at, as fractions (0-1) of the container: smallest first. */
  snapPoints?: number[];
  /** Index into snapPoints it opens at. */
  defaultSnap?: number;
  onSnapChange?: (index: number) => void;
  /** Position inside its nearest positioned ancestor instead of covering the viewport. */
  contained?: boolean;
  theme?: Theme;
  /** When false the sheet only moves between snap points and can't be closed by drag, scrim or Escape. */
  dismissible?: boolean;
  className?: string;
}) {
  const snapKey = snapPoints.join(",");
  const count = snapPoints.length || 1;
  const defIdx = clamp(Math.round(defaultSnap), 0, count - 1);
  const uid = useId();
  const titleId = uid + "-t";

  const [shown, setShown] = useState(open);
  if (open && !shown) setShown(true);
  const [snapState, setSnapState] = useState(defIdx);
  const [dragging, setDragging] = useState(false);

  const rootRef = useRef<HTMLDivElement>(null);
  const sheetRef = useRef<HTMLDivElement>(null);
  const backRef = useRef<HTMLDivElement>(null);
  const scrollRef = useRef<HTMLDivElement>(null);
  const wakeRef = useRef<() => void>(() => {});
  const moveRef = useRef<(e: Ptr) => void>(() => {});
  const endRef = useRef<(e: Ptr) => void>(() => {});
  const goRef = useRef<(i: number) => void>(() => {});
  const restoreRef = useRef<HTMLElement | null>(null);
  const live = useRef({ open, onClose, onSnapChange, dismissible, snapKey, defIdx, count });
  const eng = useRef<Engine>({
    v: 0,
    vel: 0,
    target: 0,
    H: 0,
    snaps: [],
    snap: -1,
    lastSnap: -1,
    shownSnap: -2,
    closing: false,
    raf: 0,
    last: 0,
    reduced: false,
    drag: null,
    swallow: false,
    mom: 0,
    wheelAt: 0,
    unlisten: null,
  });

  useEffect(() => {
    live.current = { open, onClose, onSnapChange, dismissible, snapKey, defIdx, count };
  });

  // The engine: one rAF loop that springs the sheet toward its target and sleeps when settled.
  useEffect(() => {
    if (!shown) return;
    const root = rootRef.current;
    if (!root) return;
    const E = eng.current;
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const onMq = () => {
      E.reduced = mq.matches;
    };
    onMq();
    mq.addEventListener("change", onMq);

    const full = () => E.snaps[E.snaps.length - 1] ?? 0;
    const wake = () => {
      if (!E.raf) {
        E.last = 0;
        E.raf = requestAnimationFrame(tick);
      }
    };
    wakeRef.current = wake;

    const apply = () => {
      const s = sheetRef.current;
      if (!s || !E.H) return;
      const f = full();
      s.style.transform = "translate3d(0," + (f + OVER - E.v).toFixed(2) + "px,0)";
      s.style.visibility = "visible";
      const b = backRef.current;
      if (b) b.style.opacity = (0.62 * Math.pow(clamp(E.v / (f || 1), 0, 1), 1.15)).toFixed(3);
    };

    const goTo = (i: number) => {
      const n = E.snaps.length;
      if (!n) return;
      const idx = clamp(i, 0, n - 1);
      E.snap = idx;
      E.lastSnap = idx;
      E.target = E.snaps[idx] ?? 0;
      E.closing = false;
      wake();
    };
    goRef.current = goTo;

    const measure = () => {
      const H = root.clientHeight;
      E.H = H;
      const fr = (live.current.snapKey || "0.5")
        .split(",")
        .map((x) => clamp(parseFloat(x) || 0.5, 0.05, 1))
        .sort((a, b) => a - b);
      E.snaps = fr.map((f) => f * H);
      const s = sheetRef.current;
      if (s) s.style.height = full() + OVER + "px";
      if (E.snap >= 0 && !E.drag) E.target = E.snaps[Math.min(E.snap, E.snaps.length - 1)] ?? 0;
      wake();
    };

    const tick = (now: number) => {
      E.raf = 0;
      const dt = E.last ? Math.min(0.032, (now - E.last) / 1000) : 1 / 60;
      E.last = now;
      let busy = false;

      const d = E.drag;
      if (d && d.mode === "active") {
        busy = true;
      } else {
        const k = E.reduced ? 640 : 300;
        const c = 2 * (E.reduced ? 1 : 0.88) * Math.sqrt(k);
        const steps = 3;
        const h = dt / steps;
        for (let i = 0; i < steps; i++) {
          E.vel += (-k * (E.v - E.target) - c * E.vel) * h;
          E.v += E.vel * h;
        }
        if (E.v < 0 && E.target === 0) {
          E.v = 0;
          E.vel = 0;
        }
        if (Math.abs(E.v - E.target) < 0.3 && Math.abs(E.vel) < 8) {
          E.v = E.target;
          E.vel = 0;
        } else busy = true;
      }

      if (E.mom) {
        const sc = scrollRef.current;
        if (sc) {
          const before = sc.scrollTop;
          sc.scrollTop = before + E.mom * dt;
          E.mom *= Math.exp(-dt * 3.4);
          if (Math.abs(E.mom) < 18 || sc.scrollTop === before) E.mom = 0;
        } else E.mom = 0;
        if (E.mom) busy = true;
      }

      apply();

      if (E.snap !== E.shownSnap) {
        E.shownSnap = E.snap;
        setSnapState(E.snap);
        if (E.snap >= 0) live.current.onSnapChange?.(E.snap);
      }

      if (!busy && E.v === 0 && E.target === 0) {
        if (E.closing) {
          E.closing = false;
          E.snap = -1;
          E.shownSnap = -2;
          setShown(false);
          return;
        }
        if (live.current.open && !E.drag) {
          // The parent kept the sheet open after a dismissal: bring it back.
          goTo(E.lastSnap >= 0 ? E.lastSnap : live.current.defIdx);
          return;
        }
      }
      if (busy) E.raf = requestAnimationFrame(tick);
    };

    const ro = new ResizeObserver(measure);
    ro.observe(root);
    measure();

    // Wheel: on a trackpad or mouse the sheet steps between detents, then the content scrolls.
    const sheet = sheetRef.current;
    const onWheel = (e: WheelEvent) => {
      if (E.drag || Math.abs(e.deltaY) < 10) return;
      const sc = scrollRef.current;
      const n = E.snaps.length;
      if (E.snap < 0 || n < 2) return;
      const atTop = !sc || sc.scrollTop <= 0;
      const stepUp = e.deltaY > 0 && E.snap < n - 1;
      const stepDown = e.deltaY < 0 && atTop && E.snap > 0;
      if (!stepUp && !stepDown) return;
      e.preventDefault();
      if (e.timeStamp - E.wheelAt < 520) return;
      E.wheelAt = e.timeStamp;
      goTo(E.snap + (stepUp ? 1 : -1));
    };
    sheet?.addEventListener("wheel", onWheel, { passive: false });

    return () => {
      cancelAnimationFrame(E.raf);
      E.raf = 0;
      E.unlisten?.();
      E.unlisten = null;
      ro.disconnect();
      mq.removeEventListener("change", onMq);
      sheet?.removeEventListener("wheel", onWheel);
    };
  }, [shown, snapKey]);

  // Open / close from the prop.
  useEffect(() => {
    const E = eng.current;
    if (open) {
      E.closing = false;
      E.drag = null;
      goRef.current(defIdx);
      if (defIdx > 0 || count === 1) {
        restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
        sheetRef.current?.focus({ preventScroll: true });
      }
    } else if (E.snap >= 0 || E.v > 0) {
      E.closing = true;
      E.target = 0;
      E.snap = -1;
      wakeRef.current();
      const r = restoreRef.current;
      restoreRef.current = null;
      if (r && document.contains(r) && sheetRef.current?.contains(document.activeElement)) r.focus({ preventScroll: true });
    }
    // Only the open flag drives this; defIdx is read when the sheet opens.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [open]);

  const modal = open && (count === 1 || snapState > 0);

  // Escape closes while the sheet is modal or holds focus.
  useEffect(() => {
    if (!open) return;
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== "Escape" || !live.current.dismissible) return;
      if (modal || sheetRef.current?.contains(document.activeElement)) {
        e.stopPropagation();
        live.current.onClose();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open, modal]);

  const dismiss = () => {
    if (live.current.dismissible) live.current.onClose();
  };

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    const E = eng.current;
    if (E.closing || (e.pointerType === "mouse" && e.button !== 0)) return;
    const t = e.target as HTMLElement;
    if (t.closest("input,textarea,select,[contenteditable='true'],[data-no-drag]")) return;
    const sheet = sheetRef.current;
    if (!sheet) return;
    const rect = sheet.getBoundingClientRect();
    const k = rect.height ? sheet.offsetHeight / rect.height : 1;
    const sc = scrollRef.current;
    E.mom = 0;
    E.swallow = false;
    E.vel = 0;
    E.drag = {
      id: e.pointerId,
      y0: e.clientY,
      lastY: e.clientY,
      k,
      raw: E.v,
      startRaw: E.v,
      mode: "pending",
      inScroll: !!sc && sc.contains(t),
      sheetMoved: false,
      hist: [{ t: e.timeStamp, raw: E.v, st: sc ? sc.scrollTop : 0 }],
    };
    // Window listeners instead of pointer capture: capture would retarget the click and break buttons inside the sheet.
    E.unlisten?.();
    const mv = (ev: PointerEvent) => moveRef.current(ev);
    const up = (ev: PointerEvent) => endRef.current(ev);
    window.addEventListener("pointermove", mv);
    window.addEventListener("pointerup", up);
    window.addEventListener("pointercancel", up);
    E.unlisten = () => {
      window.removeEventListener("pointermove", mv);
      window.removeEventListener("pointerup", up);
      window.removeEventListener("pointercancel", up);
    };
  };

  const onPointerMove = (e: Ptr) => {
    const E = eng.current;
    const d = E.drag;
    if (!d || d.id !== e.pointerId) return;
    if (d.mode === "pending") {
      if (Math.abs(e.clientY - d.y0) * d.k < 6) return;
      d.mode = "active";
      E.swallow = true;
      setDragging(true);
      wakeRef.current();
    }
    let rem = (e.clientY - d.lastY) * d.k;
    d.lastY = e.clientY;
    const f = E.snaps[E.snaps.length - 1] ?? 0;
    const sc = scrollRef.current;
    if (rem > 0 && d.raw > f) {
      const use = Math.min(rem, d.raw - f);
      d.raw -= use;
      rem -= use;
      d.sheetMoved = true;
    }
    if (d.inScroll && sc) {
      if (rem > 0 && sc.scrollTop > 0) {
        const use = Math.min(rem, sc.scrollTop);
        sc.scrollTop -= use;
        rem -= use;
      } else if (rem < 0 && d.raw >= f - 0.5) {
        const room = sc.scrollHeight - sc.clientHeight - sc.scrollTop;
        const use = Math.min(-rem, Math.max(0, room));
        sc.scrollTop += use;
        rem += use;
      }
    }
    if (rem !== 0) {
      d.raw = Math.max(0, d.raw - rem);
      d.sheetMoved = true;
    }
    E.v = band(d.raw, f);
    d.hist.push({ t: e.timeStamp, raw: d.raw, st: sc ? sc.scrollTop : 0 });
    if (d.hist.length > 12) d.hist.shift();
  };

  const onPointerEnd = (e: Ptr) => {
    eng.current.unlisten?.();
    eng.current.unlisten = null;
    const E = eng.current;
    const d = E.drag;
    if (!d || d.id !== e.pointerId) return;
    E.drag = null;
    setDragging(false);
    if (d.mode !== "active") return;
    const last = d.hist[d.hist.length - 1];
    let first = d.hist[0];
    for (const s of d.hist) {
      if (last && last.t - s.t <= 90) {
        first = s;
        break;
      }
    }
    const span = last && first ? Math.max(16, last.t - first.t) / 1000 : 0.016;
    const stale = last ? e.timeStamp - last.t > 110 : false;
    const sheetVel = !stale && last && first ? (last.raw - first.raw) / span : 0;
    const scrollVel = !stale && last && first ? -(last.st - first.st) / span : 0;
    if (e.type === "pointercancel" && !d.sheetMoved) {
      wakeRef.current();
      return;
    }
    if (!d.sheetMoved) {
      // Pure content scroll: let it coast.
      E.mom = scrollVel;
      E.vel = 0;
      E.target = E.snaps[E.snap] ?? E.target;
      wakeRef.current();
      return;
    }
    const proj = E.v + sheetVel * 0.2;
    const cands = live.current.dismissible ? [0, ...E.snaps] : E.snaps;
    let best = cands[0] ?? 0;
    for (const c of cands) if (Math.abs(c - proj) < Math.abs(best - proj)) best = c;
    if (Math.abs(d.raw - d.startRaw) < 8 && Math.abs(sheetVel) < 200) {
      best = E.snaps[E.snap] ?? best;
    }
    E.vel = E.v > (E.snaps[E.snaps.length - 1] ?? 0) ? 0 : sheetVel;
    if (best === 0 && live.current.dismissible) {
      E.target = 0;
      live.current.onClose();
      wakeRef.current();
    } else {
      const idx = E.snaps.indexOf(best);
      if (idx >= 0) goRef.current(idx);
      const ideal = E.snaps[idx] ?? best;
      E.vel = Math.abs(E.v - ideal) < 1 ? 0 : E.vel;
    }
  };

  useEffect(() => {
    moveRef.current = onPointerMove;
    endRef.current = onPointerEnd;
  });

  const onClickCapture = (e: { preventDefault: () => void; stopPropagation: () => void }) => {
    const E = eng.current;
    if (E.swallow) {
      E.swallow = false;
      e.preventDefault();
      e.stopPropagation();
    }
  };

  const onSheetKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    if (e.key !== "Tab" || !modal) return;
    const sheet = sheetRef.current;
    if (!sheet) return;
    const items = Array.from(sheet.querySelectorAll<HTMLElement>(FOCUSABLE)).filter((el) => el.offsetParent !== null || el === document.activeElement);
    const first = items[0];
    const lastEl = items[items.length - 1];
    if (!first || !lastEl) {
      e.preventDefault();
      sheet.focus();
      return;
    }
    const a = document.activeElement;
    if (e.shiftKey && (a === first || a === sheet)) {
      e.preventDefault();
      lastEl.focus();
    } else if (!e.shiftKey && a === lastEl) {
      e.preventDefault();
      first.focus();
    }
  };

  const onGripKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    const E = eng.current;
    const cur = E.snap < 0 ? snapState : E.snap;
    let next = cur;
    if (e.key === "ArrowUp" || e.key === "PageUp") next = cur + 1;
    else if (e.key === "ArrowDown" || e.key === "PageDown") next = cur - 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = count - 1;
    else if (e.key === "Enter" || e.key === " ") next = cur >= count - 1 ? 0 : cur + 1;
    else return;
    e.preventDefault();
    goRef.current(clamp(next, 0, count - 1));
  };

  const onGripClick = () => {
    const E = eng.current;
    if (E.swallow) return;
    const cur = E.snap < 0 ? snapState : E.snap;
    goRef.current(cur >= count - 1 ? 0 : cur + 1);
  };

  if (!shown) return null;

  const name = (i: number) => (count === 3 ? SNAP_NAMES[i] : undefined) ?? "Snap " + (i + 1);

  return (
    <div
      ref={rootRef}
      className={(contained ? "absolute" : "fixed") + " inset-0 z-50 overflow-hidden " + className}
      style={{ pointerEvents: "none" }}
    >
      <div
        ref={backRef}
        aria-hidden="true"
        onClick={dismiss}
        className="absolute inset-0 bg-black"
        style={{ opacity: 0, pointerEvents: modal ? "auto" : "none" }}
      />
      <div
        ref={sheetRef}
        role="dialog"
        aria-modal={modal}
        aria-labelledby={titleId}
        tabIndex={-1}
        onPointerDown={onPointerDown}
        onClickCapture={onClickCapture}
        onKeyDown={onSheetKeyDown}
        className="absolute inset-x-0 bottom-0 flex select-none flex-col rounded-t-[26px] focus:outline-none"
        style={{
          ...VARS[theme],
          backgroundColor: "var(--bs-bg)",
          color: "var(--bs-fg)",
          touchAction: "none",
          pointerEvents: "auto",
          willChange: "transform",
          visibility: "hidden",
          transform: "translate3d(0,100%,0)",
          cursor: dragging ? "grabbing" : "grab",
        }}
      >
        <div
          role="slider"
          tabIndex={0}
          aria-label="Sheet height"
          aria-orientation="vertical"
          aria-valuemin={1}
          aria-valuemax={count}
          aria-valuenow={snapState + 1}
          aria-valuetext={name(Math.max(0, snapState))}
          onKeyDown={onGripKey}
          onClick={onGripClick}
          className="group flex h-7 shrink-0 items-center justify-center focus-visible:!rounded-t-[22px] focus-visible:!outline-2 focus-visible:!outline-offset-[-3px]"
          style={{ outlineColor: "#5b8cff" }}
        >
          <span
            aria-hidden="true"
            className="block h-[5px] rounded-full transition-[width,background-color] duration-200 ease-out"
            style={{ width: dragging ? 48 : 38, background: dragging ? "var(--bs-grip-hot)" : "var(--bs-grip)" }}
          />
        </div>
        <div className="flex shrink-0 items-center justify-between gap-3 px-5 pb-3">
          <h2 id={titleId} className="m-0 min-w-0 truncate text-[19px] font-semibold leading-7 tracking-[-0.02em]">
            {title}
          </h2>
          {dismissible && (
            <button
              type="button"
              aria-label="Close"
              onClick={dismiss}
              className="-mr-2 flex h-10 w-10 shrink-0 items-center justify-center rounded-full transition-colors duration-150 focus-visible:!rounded-full focus-visible:!outline-2 focus-visible:!outline-offset-0"
              style={{ color: "var(--bs-muted)", outlineColor: "#5b8cff", cursor: "pointer" }}
            >
              <svg aria-hidden="true" viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
                <path d="M3.5 3.5l9 9M12.5 3.5l-9 9" />
              </svg>
            </button>
          )}
        </div>
        <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 text-[15px]" style={{ paddingBottom: OVER + 24, touchAction: "none", borderTop: "1px solid var(--bs-line)", paddingTop: 16 }}>
          {children}
        </div>
      </div>
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

A bottom sheet is a drag gesture with three jobs: follow the finger, decide where to land, and hand the finger over to the content. This one does all three in a single requestAnimationFrame loop that sleeps when the sheet is at rest. The sheet rests at one of three snap points (peek 30%, half 55%, full 92% of its container, all configurable through snapPoints) and is moved by translating it, so layout never changes mid-drag. On release the sheet projects where its velocity would carry it (position plus velocity times 0.2 seconds) and springs to the nearest detent, or to closed when the nearest target is the bottom; a fast short flick therefore skips a detent and a slow long drag stops at the closest one. Past the tallest detent the sheet is rubber-banded: it follows the finger at first and asymptotes about 60px beyond the end. The scrim dims with progress, from nothing when closed to 62% black at full, and only blocks the page once the sheet is above its lowest detent, so a peeking sheet leaves the page usable. Content scrolling and sheet dragging share one gesture: dragging inside the body scrolls it while it can scroll, and the moment it hits the top the same drag starts pulling the sheet down, with the same hand-off on the way up. The scroll area runs its own momentum after a flick. Props are open and onClose (controlled), title and children, plus optional snapPoints, defaultSnap (which detent it opens at), onSnapChange, contained (position inside the nearest positioned ancestor instead of covering the viewport), theme (light or dark, light by default) and dismissible. Content inside the sheet should scroll vertically only: touch-action is disabled so the drag owns the gesture, so avoid horizontal scrollers inside the body.

Curator’s note

Rebuilt in October without Framer Motion: one hand-written spring, one pointer state machine, and release velocity decides which detent the sheet lands on.

Pairs well with

Trending
Pro