Native dialog. Native scrolling.

One React component for bottom sheets, drawers, dialogs, side panels, and toasts. 17 kB gzipped, zero runtime dependencies, MIT licensed.

$bun add scrollsheet

Surfaces

Tap to open

How it stacks up

Four libraries doing the same job, including where the others win.

DimensionscrollsheetvaulSilkreact-modal-sheet
Shipped size, gzip17 kB22 kB40 kB52 kB
With toasts, gzip123.4 kB30.3 kB with sonner40 kB62 kB with sonner
Runtime dependencies0Radix Dialog0Motion (peer)
Last releaseactiveDec 2024activeactive
Toasts2yessonner drop-innoyesno
Lightboxyesnoyesno
Native <dialog>, top layer3yesnonono
Gesture enginenative scrollpointer eventsnative scrollMotion drag
Browsers without <dialog> (~4%)4plain modal, no gesturesfull supportfull supportfull support
LicenseMIT, freeMIT, freepaid for the full setMIT, free
  1. JS bundles, measured identically (esbuild, minified, react external, gzip): our whole surface in one import against vaul 1.1.2 plus sonner 2.0.8. Stylesheets on top of each: 5.5 kB ours, 1.0 kB vaul's style.css, zero for sonner (embedded). Totals 28.9 against 31.3.
  2. One package for both. It matches sonner's API, so an existing Toaster keeps working.
  3. The top layer always paints above the page, whatever your z-index. The browser traps focus and blocks clicks behind it.
  4. The one place the others win. They render a plain <div>, so their gestures keep working where ours fall back to a static modal.

What it runs on

Each feature degrades on its own. None of them take the sheet down with it.

  • <dialog>, top layer

    Stacking, without a z-index to manage

    Every engine, Mar 2022

    Chrome 37, Safari 15.4, Firefox 98

    Without itPlain modal. No gestures.

  • CSS scroll-snap

    The gesture engine, no JS in the loop

    Every engine, Jul 2019

    Chrome 69, Safari 11, Firefox 68

    Without itNothing. It ships wherever <dialog> does.

  • linear() easing

    The spring curve

    Every engine, Dec 2023

    Chrome 113, Safari 17.2, Firefox 112

    Without itEase-out, same duration.

  • Scroll-driven animations

    Backdrop dim off the main thread

    Chrome Jul 2023, Safari Sep 2025

    Chrome 115, Safari 26, Firefox behind a flag

    Without itSame values, written from JS each frame.

  • dialog.closedBy

    Close requests handled by the platform

    Chrome Mar 2025, Firefox Jul 2025

    Chrome 134, Firefox 141, not in Safari

    Without itEsc and backdrop close the manual way.

  • CloseWatcher

    Android back button on non-modal sheets

    Chrome Jun 2024, Firefox Mar 2026

    Chrome 126, Firefox 149, Safari preview only

    Without itNon-modal sheets ignore the back gesture.

Examples

Every preview runs the real package. Open Code on any card for the source — more patterns live throughout the docs.

Live and non-modal

Map search

Non-modal over a pannable map, a sticky search header that grows on focus.

maps.tsx

import * as React from "react";
import { Sheet, type DetentSpec } from "scrollsheet";

import { StarIcon } from "./icons";

interface Cafe {
  name: string;
  rating: string;
  distance: string;
}

const CAFES: Cafe[] = [
  { name: "Ember & Oak", rating: "4.7", distance: "0.3 mi" },
  { name: "Third Wave Coffee Co.", rating: "4.5", distance: "0.4 mi" },
  { name: "The Roasting Room", rating: "4.8", distance: "0.6 mi" },
  { name: "Corner Pour", rating: "4.3", distance: "0.9 mi" },
  { name: "Slate Cafe", rating: "4.6", distance: "1.1 mi" },
];

const PEEK: DetentSpec = "124px";
const TALL: DetentSpec = 0.62;

/**
 * The flex demo: non-modal so the map underneath stays pannable, a sticky
 * search header (plain position: sticky, no library API for it), and a
 * controlled activeDetent that grows on focus. Deliberately no blur-collapse:
 * iOS fires transient blurs while the keyboard settles, so collapsing on
 * blur bounces the sheet mid-keyboard — collapse stays a user gesture
 * (handle or drag). Results morph in via the sheet's own content-morph.
 */
export default function MapsExample() {
  const [open, setOpen] = React.useState(false);
  const [active, setActive] = React.useState<DetentSpec>(PEEK);
  const [query, setQuery] = React.useState("");
  const showResults = active === TALL;

  return (
    <div className="ex-map-wrap">
      <div className="ex-map" aria-hidden="true">
        <span className="ex-map-street" style={{ top: "14%", left: "10%" }}>
          5th Ave
        </span>
        <span className="ex-map-street" style={{ top: "58%", left: "62%" }}>
          Ocean Blvd
        </span>
        <span className="ex-map-street" style={{ top: "82%", left: "20%" }}>
          Elm St
        </span>
        <div className="ex-map-pin" />
      </div>
      {!open && (
        <button
          type="button"
          className="ex-trigger"
          style={{ position: "absolute", top: 14, left: 14 }}
          onClick={() => setOpen(true)}
        >
          Open map search
        </button>
      )}
      <Sheet.Root
        modal={false}
        open={open}
        onOpenChange={setOpen}
        detents={[PEEK, TALL]}
        activeDetent={active}
        onActiveDetentChange={setActive}
        themeColorDimming
      >
        <Sheet.Content className="ex-panel ex-map-panel" aria-label="Search this area">
          <Sheet.Handle />
          <div className="ex-panel-body">
            <div className="ex-map-search">
              <input
                type="search"
                className="ex-input"
                placeholder="Search coffee near you"
                aria-label="Search this area"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                onFocus={() => setActive(TALL)}
              />
            </div>
            {showResults && (
              <div className="ex-map-results ex-map-results-scroll">
                {CAFES.map((cafe) => (
                  <div className="ex-map-result" key={cafe.name}>
                    <div className="ex-map-result-name">{cafe.name}</div>
                    <div className="ex-map-result-meta">
                      <StarIcon className="ex-map-star" /> {cafe.rating} · {cafe.distance}
                    </div>
                  </div>
                ))}
              </div>
            )}
          </div>
        </Sheet.Content>
      </Sheet.Root>
    </div>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-body {
  padding: 4px 22px 28px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

.ex-input,
.ex-textarea {
  display: block;
  width: 100%;
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  border-radius: var(--ex-radius-sm);
  padding: 12px 14px;
  font-size: 1rem;
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  font-family: inherit;
}

.ex-input:focus-visible,
.ex-textarea:focus-visible {
  outline: 2px solid var(--ex-accent);
  outline-offset: 1px;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

/* ── Maps ────────────────────────────────────────────────────────────────── */

.ex-map-wrap {
  position: relative;
  width: 100%;
  max-width: 380px;
  /* Same height as .ex-ride-map-card: the two live/non-modal previews sit
  next to each other and read as one pair only if their canvases match. */
  height: 200px;
  border-radius: var(--ex-radius-md);
  overflow: hidden;
  box-shadow: var(--ex-shadow-sm);
}

.ex-map {
  position: absolute;
  inset: 0;
  background:
  linear-gradient(135deg, rgb(90 69 232 / 0.14), transparent 60%),
  repeating-linear-gradient(0deg, var(--ex-border) 0 1px, transparent 1px 64px),
  repeating-linear-gradient(90deg, var(--ex-border) 0 1px, transparent 1px 64px),
  var(--ex-bg-inset);
}

.ex-map-street {
  position: absolute;
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ex-fg-faint);
  text-transform: uppercase;
  pointer-events: none;
}

.ex-map-pin {
  position: absolute;
  top: 46%;
  left: 48%;
  width: 16px;
  height: 16px;
  border-radius: 50% 50% 50% 0;
  background: var(--ex-accent);
  transform: rotate(-45deg);
  box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
}

.ex-map-search {
  /* The search field never scrolls: the results list below owns its own
  overflow instead, which holds on every engine (sticky inside a
  toggling-overflow panel is flaky on iOS Safari). */
  background: var(--ex-bg-elevated);
  /* Clear of the Handle's 44px hit box: with the input flush under the
  pill, iPhone touch adjustment snaps taps aimed at the field's top half
  onto the handle button — the sheet cycles detents and the keyboard
  never opens. 16px puts the whole field outside the snap contest. */
  margin-top: 16px;
  padding-bottom: 12px;
  margin-bottom: 4px;
}

/* Flex column down the panel so the results list is sized by what's left
   after the search row — exactly the sheet's remaining height, keyboard or
   not — instead of a hardcoded vh guess that either clips short or leaves
   dead space when the keyboard caps the panel at the visual viewport. The
   chain passes through the library's [data-scrollsheet-body] wrapper (a
   documented hook); the library can't stretch it by default because the
   'content' detent measures that wrapper's natural height. */
.ex-map-panel {
  display: flex;
  flex-direction: column;
}

.ex-map-panel [data-scrollsheet-body] {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.ex-map-panel .ex-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

.ex-map-results-scroll {
  overflow-y: auto;
  overscroll-behavior: auto;
  flex: 1;
  min-height: 0;
}

.ex-map-results {
  padding-bottom: 8px;
}

.ex-map-result {
  padding: 12px 0;
  border-bottom: 1px solid var(--ex-border);
}

.ex-map-result:last-child {
  border-bottom: none;
}

.ex-map-result-name {
  font-weight: 600;
  font-size: 0.95rem;
}

.ex-map-result-meta {
  color: var(--ex-fg-muted);
  font-size: 0.82rem;
  margin-top: 2px;
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

/* ── rich: ride ─────────────────────────────────────────────────────── */

/* Same grid-and-gradient backdrop as .ex-map (Map search): the two examples
   sit in the same gallery group, and matching the canvas is most of what
   makes this one read as a card instead of an illustration. */
.ex-ride-map-card {
  position: relative;
  width: 100%;
  max-width: 380px;
  height: 200px;
  border-radius: var(--ex-radius-md);
  overflow: hidden;
  box-shadow: var(--ex-shadow-sm);
  margin-bottom: 4px;
  background:
  linear-gradient(135deg, rgb(90 69 232 / 0.14), transparent 60%),
  repeating-linear-gradient(0deg, var(--ex-border) 0 1px, transparent 1px 64px),
  repeating-linear-gradient(90deg, var(--ex-border) 0 1px, transparent 1px 64px),
  var(--ex-bg-inset);
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

icons.tsx

/**
 * Inline lucide icons (ISC) used by the examples — kept dependency-free so
 * the examples stay copy-pasteable without an icon package install.
 * Generated file: edit the generator, not this by hand.
 */
import * as React from "react";

function Icon({ children, ...props }: React.ComponentProps<"svg">) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

export function StarIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.12 2.12 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.12 2.12 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.12 2.12 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.12 2.12 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.12 2.12 0 0 0 1.597-1.16z" />
    </Icon>
  );
}

Menus and actions

Wallet menu

A detached floating card: multi-view morph from menu to detail to confirm.

wallet.tsx

import * as React from "react";
import { Sheet } from "scrollsheet";

type View = "menu" | "key" | "remove";

/**
 * Multi-view morph: switching views changes content height, and the sheet
 * springs to the new height automatically via the built-in ResizeObserver,
 * no JS animation code for that part. The incoming view's rows still need
 * their own reveal on mount (examples.css: .ex-wallet-view), since a plain
 * conditional render otherwise just pops the new content in with no motion.
 */
export default function WalletExample() {
  const [view, setView] = React.useState<View>("menu");
  const [open, setOpen] = React.useState(false);

  return (
    <Sheet.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (!next) setView("menu");
      }}
      themeColorDimming
    >
      <Sheet.Trigger className="ex-trigger">Wallet options</Sheet.Trigger>
      <Sheet.Content className="ex-panel-inset" aria-label="Wallet options">
        <div className="ex-panel-pad">
          {view === "menu" && (
            <div className="ex-wallet-view">
              <Sheet.Title>Options</Sheet.Title>
              <button
                type="button"
                className="ex-menu-item"
                data-scrollsheet-no-drag
                onClick={() => setView("key")}
              >
                View private key
              </button>
              <button
                type="button"
                className="ex-menu-item"
                data-scrollsheet-no-drag
                onClick={() => setView("remove")}
              >
                Remove wallet
              </button>
              <Sheet.Close className="ex-menu-item ex-menu-cancel" data-scrollsheet-no-drag>
                Cancel
              </Sheet.Close>
            </div>
          )}
          {view === "key" && (
            <div className="ex-wallet-view">
              <Sheet.Title>Private key</Sheet.Title>
              <p className="ex-note" style={{ textAlign: "left", maxWidth: "none" }}>
                Your private key is what backs up this wallet. Keep it secret and secure at all
                times.
              </p>
              <ul className="ex-hint-list">
                <li>Keep your private key safe</li>
                <li>Do not share it with anyone else</li>
                <li>Never enter it into a site you did not expect</li>
              </ul>
              <button
                type="button"
                className="ex-menu-item"
                data-scrollsheet-no-drag
                onClick={() => setView("menu")}
              >
                Back
              </button>
            </div>
          )}
          {view === "remove" && (
            <div className="ex-wallet-view">
              <Sheet.Title>Are you sure?</Sheet.Title>
              <p className="ex-note" style={{ textAlign: "left", maxWidth: "none" }}>
                You have not backed up this wallet yet. Removing it now means losing access for
                good.
              </p>
              <button
                type="button"
                className="ex-menu-item"
                data-scrollsheet-no-drag
                onClick={() => setView("menu")}
              >
                Back
              </button>
              <Sheet.Close className="ex-menu-item ex-menu-danger" data-scrollsheet-no-drag>
                Continue
              </Sheet.Close>
            </div>
          )}
        </div>
      </Sheet.Content>
    </Sheet.Root>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-inset {
  --scrollsheet-inset-x: 12px;
  --scrollsheet-inset-bottom: 12px;
  border-radius: var(--ex-radius-xl);
  box-shadow: var(--ex-shadow-lg);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
}

.ex-panel-pad {
  padding: 26px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-pad h2 {
  font-size: 1.1rem;
  margin: 0 0 14px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

.ex-note {
  color: var(--ex-fg-faint);
  font-size: 0.82rem;
  margin-top: 10px;
  text-align: center;
  max-width: 34ch;
}

/* ── Menu list (wallet) ──────────────────────────────────────────────────── */

.ex-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  appearance: none;
  border: none;
  background: var(--ex-bg-inset);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-md);
  padding: 14px 16px;
  font-size: 0.98rem;
  font-weight: 550;
  margin-bottom: 8px;
  cursor: pointer;
  font-family: inherit;
}

.ex-menu-item:hover {
  background: var(--ex-border);
}

.ex-menu-item:last-child {
  margin-bottom: 0;
}

.ex-menu-cancel {
  background: transparent;
  border: 1px solid var(--ex-border-strong);
}

.ex-menu-danger {
  background: var(--ex-danger-soft);
  color: var(--ex-danger);
}

.ex-hint-list {
  margin: 0 0 16px;
  padding-left: 18px;
  color: var(--ex-fg-muted);
  line-height: 1.7;
}

/* Each view's rows rise and fade in on mount, staggered by position, so
   switching views (menu -> key -> remove) reads as a step forward instead of
   the new content just popping in under the sheet's own height spring.
   transform/opacity only; timed off the library's own spring easing/duration
   (set on .scrollsheet-dialog, inherited here) so it matches the sheet's
   motion instead of inventing a second one. */
.ex-wallet-view > * {
  animation: ex-wallet-in var(--scrollsheet-dur, 280ms) var(--scrollsheet-ease, ease) both;
}

.ex-wallet-view > *:nth-child(1) {
  animation-delay: 0ms;
}

.ex-wallet-view > *:nth-child(2) {
  animation-delay: 40ms;
}

.ex-wallet-view > *:nth-child(3) {
  animation-delay: 80ms;
}

.ex-wallet-view > *:nth-child(4) {
  animation-delay: 120ms;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .ex-wallet-view > * {
    animation: none;
  }
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

Content and forms

Cart and checkout

dismissible={false} while checkout is processing, back on when it settles.

cart.tsx

import * as React from "react";
import { Sheet } from "scrollsheet";

interface Item {
  name: string;
  qty: number;
  price: number;
  color: string;
}

const ITEMS: Item[] = [
  { name: "Ceramic pour-over kettle", qty: 1, price: 58, color: "#d7c4a3" },
  { name: "Single-origin beans, 12oz", qty: 2, price: 16, color: "#8a5a3b" },
  { name: "Filter papers, 100ct", qty: 1, price: 9, color: "#e8e2d6" },
];

function currency(value: number): string {
  return `$${value.toFixed(2)}`;
}

/**
 * dismissible={!processing}: swipe/backdrop/Esc all stop closing the sheet
 * for the couple of seconds a real checkout call would take.
 */
export default function CartExample() {
  const [open, setOpen] = React.useState(false);
  const [processing, setProcessing] = React.useState(false);
  const [placed, setPlaced] = React.useState(false);

  React.useEffect(() => {
    if (!processing) return;
    const timer = setTimeout(() => {
      setProcessing(false);
      setPlaced(true);
    }, 1400);
    return () => clearTimeout(timer);
  }, [processing]);

  const subtotal = ITEMS.reduce((sum, item) => sum + item.price * item.qty, 0);

  return (
    <Sheet.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (!next) {
          setProcessing(false);
          setPlaced(false);
        }
      }}
      dismissible={!processing}
      themeColorDimming
    >
      <Sheet.Trigger className="ex-trigger">View cart</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Cart">
        <Sheet.Handle />
        <div className="ex-panel-body">
          <Sheet.Title>Your cart</Sheet.Title>
          {placed ? (
            <div>
              <p style={{ color: "var(--ex-fg-muted)" }}>
                Order placed. A confirmation is on its way to your inbox.
              </p>
              <Sheet.Close className="ex-btn ex-btn-block" data-scrollsheet-no-drag>
                Done
              </Sheet.Close>
            </div>
          ) : (
            <div>
              {ITEMS.map((item) => (
                <div className="ex-cart-item" key={item.name}>
                  <div className="ex-cart-thumb" style={{ background: item.color }} />
                  <div className="ex-cart-info">
                    <div className="ex-cart-name">{item.name}</div>
                    <div className="ex-cart-qty">Qty {item.qty}</div>
                  </div>
                  <div className="ex-cart-price">{currency(item.price * item.qty)}</div>
                </div>
              ))}
              <div className="ex-cart-summary">
                <div className="ex-cart-summary-row">
                  <span>Shipping</span>
                  <span>Free</span>
                </div>
                <div className="ex-cart-total">
                  <span>Subtotal</span>
                  <span>{currency(subtotal)}</span>
                </div>
              </div>
              <button
                type="button"
                className="ex-btn ex-btn-primary ex-btn-block"
                data-scrollsheet-no-drag
                disabled={processing}
                onClick={() => setProcessing(true)}
              >
                {processing ? (
                  <>
                    <span className="ex-spinner" aria-hidden="true" />
                    Processing
                  </>
                ) : (
                  "Checkout"
                )}
              </button>
            </div>
          )}
        </div>
      </Sheet.Content>
    </Sheet.Root>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

.ex-btn {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 16px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}

.ex-btn:hover {
  border-color: var(--ex-accent);
}

.ex-btn-primary {
  background: var(--ex-accent);
  border-color: var(--ex-accent);
  color: var(--ex-accent-fg);
}

.ex-btn-primary:hover {
  filter: brightness(1.06);
  border-color: var(--ex-accent);
}

.ex-btn-block {
  display: block;
  width: 100%;
  text-align: center;
}

.ex-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.ex-actions .ex-btn {
  flex: 1;
  justify-content: center;
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-body {
  padding: 4px 22px 28px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.ex-map-panel .ex-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

/* ── Cart ────────────────────────────────────────────────────────────────── */

.ex-cart-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 0;
  border-bottom: 1px solid var(--ex-border);
}

.ex-cart-thumb {
  flex: none;
  width: 44px;
  height: 44px;
  border-radius: var(--ex-radius-sm);
  background: var(--ex-thumb-color, var(--ex-accent-soft));
}

.ex-cart-info {
  flex: 1;
  min-width: 0;
}

.ex-cart-name {
  font-weight: 600;
  font-size: 0.92rem;
}

.ex-cart-qty {
  color: var(--ex-fg-muted);
  font-size: 0.8rem;
}

.ex-cart-price {
  font-family: var(--ex-font-mono);
  font-size: 0.9rem;
  flex: none;
}

.ex-cart-summary {
  padding-top: 14px;
  margin-top: 4px;
}

.ex-cart-summary-row {
  display: flex;
  justify-content: space-between;
  font-size: 0.88rem;
  color: var(--ex-fg-muted);
  margin-bottom: 6px;
}

.ex-cart-total {
  display: flex;
  justify-content: space-between;
  font-weight: 700;
  font-size: 1.02rem;
  margin: 10px 0 18px;
}

.ex-spinner {
  width: 16px;
  height: 16px;
  border-radius: 50%;
  border: 2px solid rgb(255 255 255 / 0.4);
  border-top-color: #fff;
  display: inline-block;
  margin-right: 8px;
  animation: ex-spin 700ms linear infinite;
  vertical-align: -3px;
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

.ex-ride-stops .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

.ex-page-actions .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

.ex-side-row .ex-btn {
  text-transform: capitalize;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

Live and non-modal

Toast

toast() and Toaster, built in: stacked cards, self-dismissing, swipe to dismiss.

toast.tsx

import * as React from "react";
import { Toaster, toast } from "scrollsheet";

/**
 * The library's own toast system: a persistent element per toast, stacking
 * with per-toast recede, six positions, swipe to dismiss. The `toasterId`
 * pins these toasts to this example's own Toaster, so a page with another
 * (un-keyed) Toaster mounted never renders them twice.
 */
const TOASTER = "toast-example";

export default function ToastExample() {
  return (
    <>
      <div className="ex-side-row">
        <button
          type="button"
          className="ex-btn"
          onClick={() => toast("Saved to your list", { toasterId: TOASTER })}
        >
          Toast
        </button>
        <button
          type="button"
          className="ex-btn"
          onClick={() =>
            toast.success("Synced", { description: "Two seconds ago.", toasterId: TOASTER })
          }
        >
          Success
        </button>
        <button
          type="button"
          className="ex-btn"
          onClick={() =>
            toast("Message archived", {
              toasterId: TOASTER,
              action: { label: "Undo", onClick: () => toast("Restored", { toasterId: TOASTER }) },
            })
          }
        >
          Action
        </button>
      </div>
      <Toaster id={TOASTER} />
    </>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

.ex-btn {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 16px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}

.ex-btn:hover {
  border-color: var(--ex-accent);
}

.ex-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.ex-actions .ex-btn {
  flex: 1;
  justify-content: center;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

.ex-ride-stops .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

.ex-page-actions .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

/* Four-sides example: the variant row. */
.ex-side-row {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.ex-side-row .ex-btn {
  text-transform: capitalize;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

Nested sheets

Two levels

A sheet opened from inside a sheet. The parent recedes automatically.

nested.tsx

import * as React from "react";
import { Sheet } from "scrollsheet";

import {
  CheckIcon,
  FolderIcon,
  ForwardIcon,
  LayoutGridIcon,
  LogOutIcon,
  MessageCircleIcon,
  UsersIcon,
} from "./icons";

type ThemeName = "Light" | "Dark" | "System";

interface ThemeOption {
  name: ThemeName;
  bg: string;
  accent: string;
}

const THEMES: ThemeOption[] = [
  { name: "Light", bg: "#ffffff", accent: "#5a45e8" },
  { name: "Dark", bg: "#1c1c1e", accent: "#9385ff" },
  { name: "System", bg: "linear-gradient(135deg, #ffffff 50%, #1c1c1e 50%)", accent: "#78716c" },
];

/**
 * A settings list that opens a theme picker from inside itself. The parent
 * recedes, iOS-style, and that handoff is the point: the list and picker
 * are just a realistic place to see it happen.
 */
export default function NestedExample() {
  const [theme, setTheme] = React.useState<ThemeName>("System");

  return (
    <Sheet.Root detents={[0.6]} themeColorDimming>
      <Sheet.Trigger className="ex-trigger">Open settings</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Settings">
        <Sheet.Handle />
        <div className="ex-panel-body">
          <Sheet.Title>Settings</Sheet.Title>
          <Sheet.Description>
            Open Appearance and watch this sheet recede: scale down and dim behind the child. The
            platform owns the stacking order, there is no z-index to manage here.
          </Sheet.Description>
          <div className="ex-settings-list">
            <button type="button" className="ex-settings-row">
              <span className="ex-settings-icon">
                <UsersIcon />
              </span>
              <span className="ex-settings-label">Account</span>
              <ForwardIcon className="ex-settings-chevron" />
            </button>
            <button type="button" className="ex-settings-row">
              <span className="ex-settings-icon">
                <MessageCircleIcon />
              </span>
              <span className="ex-settings-label">Notifications</span>
              <ForwardIcon className="ex-settings-chevron" />
            </button>
            <Sheet.Root themeColorDimming>
              <Sheet.Trigger className="ex-settings-row" data-scrollsheet-no-drag>
                <span className="ex-settings-icon">
                  <LayoutGridIcon />
                </span>
                <span className="ex-settings-label">Appearance</span>
                <span className="ex-settings-value">{theme}</span>
                <ForwardIcon className="ex-settings-chevron" />
              </Sheet.Trigger>
              <Sheet.Content className="ex-panel" aria-label="Appearance">
                <Sheet.Handle />
                <div className="ex-panel-body">
                  <Sheet.Title>Appearance</Sheet.Title>
                  <Sheet.Description>
                    Stacked in the top layer by the platform. Dismiss to bring Settings back.
                  </Sheet.Description>
                  <div className="ex-theme-grid">
                    {THEMES.map((option) => (
                      <button
                        type="button"
                        key={option.name}
                        className={
                          option.name === theme ? "ex-theme-card is-selected" : "ex-theme-card"
                        }
                        onClick={() => setTheme(option.name)}
                      >
                        <span className="ex-theme-swatch" style={{ background: option.bg }}>
                          <span
                            className="ex-theme-swatch-dot"
                            style={{ background: option.accent }}
                          />
                        </span>
                        <span className="ex-theme-name">{option.name}</span>
                        {option.name === theme && (
                          <span className="ex-theme-check" aria-hidden="true">
                            <CheckIcon />
                          </span>
                        )}
                      </button>
                    ))}
                  </div>
                  <Sheet.Close className="ex-btn ex-btn-block" data-scrollsheet-no-drag>
                    Done
                  </Sheet.Close>
                </div>
              </Sheet.Content>
            </Sheet.Root>
            <button type="button" className="ex-settings-row">
              <span className="ex-settings-icon">
                <FolderIcon />
              </span>
              <span className="ex-settings-label">Storage</span>
              <ForwardIcon className="ex-settings-chevron" />
            </button>
            <Sheet.Close
              className="ex-settings-row ex-settings-row-danger"
              data-scrollsheet-no-drag
            >
              <span className="ex-settings-icon">
                <LogOutIcon />
              </span>
              <span className="ex-settings-label">Sign out</span>
            </Sheet.Close>
          </div>
        </div>
      </Sheet.Content>
    </Sheet.Root>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

.ex-btn {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 16px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}

.ex-btn:hover {
  border-color: var(--ex-accent);
}

.ex-btn-block {
  display: block;
  width: 100%;
  text-align: center;
}

.ex-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.ex-actions .ex-btn {
  flex: 1;
  justify-content: center;
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-body {
  padding: 4px 22px 28px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.ex-map-panel .ex-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

.ex-ride-stops .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

.ex-page-actions .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

/* ── rich: nested ─────────────────────────────────────────────────────── */

.ex-settings-list {
  margin-top: 4px;
}

.ex-settings-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  appearance: none;
  border: none;
  border-bottom: 1px solid var(--ex-border);
  background: transparent;
  color: var(--ex-fg);
  text-align: left;
  font-size: 0.95rem;
  font-weight: 550;
  font-family: inherit;
  padding: 13px 2px;
  cursor: pointer;
}

.ex-settings-row:last-child {
  border-bottom: none;
}

.ex-settings-row:hover {
  background: var(--ex-bg-inset);
}

.ex-settings-row-danger {
  color: var(--ex-danger);
}

.ex-settings-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 30px;
  height: 30px;
  border-radius: var(--ex-radius-sm);
  background: var(--ex-accent-soft);
  color: var(--ex-accent);
  flex: none;
  font-size: 1rem;
}

.ex-settings-row-danger .ex-settings-icon {
  background: var(--ex-danger-soft);
  color: var(--ex-danger);
}

.ex-settings-label {
  flex: 1;
  min-width: 0;
}

.ex-settings-value {
  color: var(--ex-fg-muted);
  font-size: 0.85rem;
  font-weight: 500;
}

.ex-settings-chevron {
  color: var(--ex-fg-faint);
  font-size: 0.85rem;
  flex: none;
}

.ex-theme-grid {
  display: flex;
  gap: 10px;
  margin-bottom: 16px;
}

.ex-theme-card {
  position: relative;
  flex: 1;
  appearance: none;
  border: 2px solid var(--ex-border);
  background: var(--ex-bg-inset);
  border-radius: var(--ex-radius-md);
  padding: 10px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  font-family: inherit;
}

.ex-theme-card.is-selected {
  border-color: var(--ex-accent);
}

.ex-theme-swatch {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  width: 100%;
  height: 44px;
  padding-bottom: 8px;
  border-radius: var(--ex-radius-sm);
  border: 1px solid var(--ex-border-strong);
}

.ex-theme-swatch-dot {
  width: 14px;
  height: 14px;
  border-radius: 50%;
}

.ex-theme-name {
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--ex-fg);
}

.ex-theme-card.is-selected .ex-theme-name {
  color: var(--ex-accent);
}

.ex-theme-check {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--ex-accent);
  color: var(--ex-accent-fg);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.6rem;
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

.ex-side-row .ex-btn {
  text-transform: capitalize;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

icons.tsx

/**
 * Inline lucide icons (ISC) used by the examples — kept dependency-free so
 * the examples stay copy-pasteable without an icon package install.
 * Generated file: edit the generator, not this by hand.
 */
import * as React from "react";

function Icon({ children, ...props }: React.ComponentProps<"svg">) {
  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width="1em"
      height="1em"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      {...props}
    >
      {children}
    </svg>
  );
}

export function CheckIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <path d="M20 6L9 17l-5-5" />
    </Icon>
  );
}

export function FolderIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
    </Icon>
  );
}

export function ForwardIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <g>
        <path d="m15 17l5-5l-5-5" />
        <path d="M4 18v-2a4 4 0 0 1 4-4h12" />
      </g>
    </Icon>
  );
}

export function LayoutGridIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <g>
        <rect width="7" height="7" x="3" y="3" rx="1" />
        <rect width="7" height="7" x="14" y="3" rx="1" />
        <rect width="7" height="7" x="14" y="14" rx="1" />
        <rect width="7" height="7" x="3" y="14" rx="1" />
      </g>
    </Icon>
  );
}

export function LogOutIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
    </Icon>
  );
}

export function MessageCircleIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <path d="M2.992 16.342a2 2 0 0 1 .094 1.167l-1.065 3.29a1 1 0 0 0 1.236 1.168l3.413-.998a2 2 0 0 1 1.099.092a10 10 0 1 0-4.777-4.719" />
    </Icon>
  );
}

export function UsersIcon(props: React.ComponentProps<"svg">) {
  return (
    <Icon {...props}>
      <g>
        <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M16 3.128a4 4 0 0 1 0 7.744M22 21v-2a4 4 0 0 0-3-3.87" />
        <circle cx="9" cy="7" r="4" />
      </g>
    </Icon>
  );
}

Mechanics

Snap heights

Three resting heights, 35%, 70%, and full, snapped to by the browser.

snap-heights.tsx

import * as React from "react";
import { Sheet, type DetentSpec } from "scrollsheet";

const SETTINGS = [
  { label: "Notifications", hint: "Alerts, sounds, and badges" },
  { label: "Appearance", hint: "Theme, accent color, text size" },
  { label: "Privacy and security", hint: "Screen lock, data sharing" },
  { label: "Storage and data", hint: "Downloads, cache, backups" },
  { label: "Accessibility", hint: "Contrast, motion, captions" },
  { label: "Language and region", hint: "App language, date format" },
  { label: "Connected accounts", hint: "Linked services and apps" },
  { label: "Help and support", hint: "Guides, contact, feedback" },
];

const DETENTS: DetentSpec[] = [0.35, 0.7, "full"];

export default function SnapHeightsExample() {
  const [active, setActive] = React.useState<DetentSpec>(DETENTS[0]!);

  return (
    <Sheet.Root
      detents={DETENTS}
      activeDetent={active}
      onActiveDetentChange={setActive}
      themeColorDimming
    >
      <Sheet.Trigger className="ex-trigger">Snap heights</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Settings">
        <Sheet.Handle />
        <div className="ex-panel-body">
          <Sheet.Title>Settings</Sheet.Title>
          <Sheet.Description>
            Active detent: <code>{String(active)}</code>. Drag between 35%, 70%, and full, or click
            the handle or use arrow keys.
          </Sheet.Description>
          {SETTINGS.map((item) => (
            <div className="ex-row" key={item.label}>
              <div className="ex-row-body">
                <div className="ex-row-title">{item.label}</div>
                <div className="ex-row-sub">{item.hint}</div>
              </div>
            </div>
          ))}
        </div>
      </Sheet.Content>
    </Sheet.Root>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-body {
  padding: 4px 22px 28px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

/* ── Generic rows ────────────────────────────────────────────────────────── */

.ex-row {
  padding: 12px 0;
  border-bottom: 1px solid var(--ex-border);
  display: flex;
  align-items: center;
  gap: 12px;
  font-size: 0.95rem;
}

.ex-row:last-child {
  border-bottom: none;
}

.ex-row-body {
  flex: 1;
  min-width: 0;
}

.ex-row-title {
  font-weight: 600;
}

.ex-row-sub {
  color: var(--ex-fg-muted);
  font-size: 0.85rem;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.ex-map-panel .ex-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

.ex-lb-caption .ex-row-sub {
  color: rgb(255 255 255 / 0.75);
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

Content and forms

Profile form

handleOnly: fields pan and select freely, only the handle drags the sheet.

profile-form.tsx

import { Sheet } from "scrollsheet";

/**
 * A text input inside a sheet, with enough fields below it to test on-device
 * keyboard avoidance: focusing a field should bring it above the software
 * keyboard rather than letting the keyboard cover it.
 */
export default function ProfileFormExample() {
  return (
    <Sheet.Root detents={[0.6, "full"]} handleOnly themeColorDimming>
      <Sheet.Trigger className="ex-trigger">Edit profile</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Edit profile">
        <Sheet.Handle />
        <div className="ex-panel-body">
          <Sheet.Title>Edit profile</Sheet.Title>
          <Sheet.Description>
            On a real device, focus a field below and confirm the on-screen keyboard never covers
            it.
          </Sheet.Description>
          <div className="ex-field">
            <label className="ex-label" htmlFor="ex-profile-name">
              Name
            </label>
            <input id="ex-profile-name" className="ex-input" defaultValue="Priya Nair" />
          </div>
          <div className="ex-field">
            <label className="ex-label" htmlFor="ex-profile-email">
              Email
            </label>
            <input
              id="ex-profile-email"
              type="email"
              className="ex-input"
              defaultValue="[email protected]"
            />
          </div>
          <div className="ex-field">
            <label className="ex-label" htmlFor="ex-profile-location">
              Location
            </label>
            <input id="ex-profile-location" className="ex-input" defaultValue="Bengaluru, India" />
          </div>
          <div className="ex-field">
            <label className="ex-label" htmlFor="ex-profile-bio">
              Bio
            </label>
            <textarea
              id="ex-profile-bio"
              className="ex-textarea"
              rows={3}
              defaultValue="Product designer working on developer tools. Coffee, trail running, and side projects that never ship."
            />
          </div>
          <button
            type="button"
            className="ex-btn ex-btn-primary ex-btn-block"
            data-scrollsheet-no-drag
          >
            Save changes
          </button>
        </div>
      </Sheet.Content>
    </Sheet.Root>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

.ex-btn {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 16px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}

.ex-btn:hover {
  border-color: var(--ex-accent);
}

.ex-btn-primary {
  background: var(--ex-accent);
  border-color: var(--ex-accent);
  color: var(--ex-accent-fg);
}

.ex-btn-primary:hover {
  filter: brightness(1.06);
  border-color: var(--ex-accent);
}

.ex-btn-block {
  display: block;
  width: 100%;
  text-align: center;
}

.ex-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.ex-actions .ex-btn {
  flex: 1;
  justify-content: center;
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-body {
  padding: 4px 22px 28px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

/* ── Form fields ─────────────────────────────────────────────────────────── */

.ex-field {
  margin-bottom: 14px;
}

.ex-label {
  display: block;
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--ex-fg-muted);
  margin-bottom: 6px;
}

.ex-input,
.ex-textarea {
  display: block;
  width: 100%;
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  border-radius: var(--ex-radius-sm);
  padding: 12px 14px;
  font-size: 1rem;
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  font-family: inherit;
}

.ex-textarea {
  resize: vertical;
}

.ex-input:focus-visible,
.ex-textarea:focus-visible {
  outline: 2px solid var(--ex-accent);
  outline-offset: 1px;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

.ex-map-panel .ex-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

.ex-ride-stops .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

.ex-page-actions .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

.ex-side-row .ex-btn {
  text-transform: capitalize;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

Mechanics

Four sides

side: the same sheet anchored to the bottom, top, left, or right edge.

sides.tsx

import * as React from "react";
import { Sheet } from "scrollsheet";

const SIDES = ["top", "bottom", "left", "right"] as const;
type Side = (typeof SIDES)[number];

/**
 * One sheet, four edges: `side` is the only prop that changes. Top and
 * bottom sheets resolve detents as heights, left and right as widths —
 * everything else (drag, snap, dimming, dismissal) is identical.
 */
export default function SidesExample() {
  // `side` outlives `open` on purpose: clearing it while the sheet is
  // closing would swap the panel to bottom-sheet geometry mid-animation.
  const [side, setSide] = React.useState<Side>("bottom");
  const [open, setOpen] = React.useState(false);

  return (
    <>
      <div className="ex-side-row">
        {SIDES.map((s) => (
          <button
            key={s}
            type="button"
            className="ex-btn"
            onClick={() => {
              setSide(s);
              setOpen(true);
            }}
          >
            {s}
          </button>
        ))}
      </div>
      <Sheet.Root
        side={side}
        detents={side === "left" || side === "right" ? ["280px"] : [0.4]}
        themeColorDimming
        open={open}
        onOpenChange={setOpen}
      >
        <Sheet.Content className="ex-panel" aria-label={`${side} sheet`}>
          {(side === "bottom" || side === "top") && <Sheet.Handle />}
          <div className="ex-panel-pad">
            <Sheet.Title>side="{side}"</Sheet.Title>
            <Sheet.Description>
              Same component, anchored to the {side} edge. Drag it back off that edge
              to dismiss, or tap the backdrop.
            </Sheet.Description>
          </div>
        </Sheet.Content>
      </Sheet.Root>
    </>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

.ex-btn {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 16px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}

.ex-btn:hover {
  border-color: var(--ex-accent);
}

.ex-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.ex-actions .ex-btn {
  flex: 1;
  justify-content: center;
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-pad {
  padding: 26px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-pad h2 {
  font-size: 1.1rem;
  margin: 0 0 14px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

.ex-ride-stops .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

.ex-page-actions .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

/* Four-sides example: the variant row. */
.ex-side-row {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.ex-side-row .ex-btn {
  text-transform: capitalize;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}

Menus and actions

Non-dismissible confirm

dismissible={false} plus disableDrag: a decision point that holds still.

confirm.tsx

import * as React from "react";
import { Sheet } from "scrollsheet";

/**
 * dismissible={false}: swipe-down, backdrop tap, and Esc all stop closing
 * the sheet. Only the two buttons below can.
 */
export default function ConfirmExample() {
  const [open, setOpen] = React.useState(false);

  return (
    <Sheet.Root
      open={open}
      onOpenChange={setOpen}
      dismissible={false}
      disableDrag
      themeColorDimming
    >
      <Sheet.Trigger className="ex-trigger">Discard changes</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Discard unsaved changes">
        <div className="ex-panel-pad">
          <div className="ex-confirm-icon" aria-hidden="true">
            !
          </div>
          <Sheet.Title>Discard unsaved changes?</Sheet.Title>
          <Sheet.Description>
            You have edits that have not been saved yet. They will be lost if you leave now. Swipe,
            backdrop tap, and Esc are disabled here, only the buttons below close this.
          </Sheet.Description>
          <div className="ex-actions">
            <button
              type="button"
              className="ex-btn"
              data-scrollsheet-no-drag
              onClick={() => setOpen(false)}
            >
              Keep editing
            </button>
            <button
              type="button"
              className="ex-btn ex-btn-danger"
              data-scrollsheet-no-drag
              onClick={() => setOpen(false)}
            >
              Discard
            </button>
          </div>
        </div>
      </Sheet.Content>
    </Sheet.Root>
  );
}

styles.css

Only the rules this example uses. Save it next to the component and it runs standalone.

/* ─────────────────────────────────────────────────────────────────────────
   Shared example styling. Imported once by each app (playground/site) so
   every example in this directory looks the same everywhere it runs, without
   any example .tsx importing anything but "scrollsheet" and "react".

   Self-contained token set (--ex-*), namespaced so it never collides with a
   host app's own design tokens. scrollsheet itself ships a real default
   look for the panel (background/radius/shadow, see src/internal/styles.ts).
   Everything below styles the *content* inside it, plus a few panel
   variants (inset card, toast) that intentionally override that default.
   ───────────────────────────────────────────────────────────────────────── */

:root {
  /* The edge-attached panel surface. Deliberately equal to the host page's
  own background so that a bottom sheet, the page behind it, and the
  page's <meta name="theme-color"> are all one color: theme-color is a
  single page-level value that browsers apply to every piece of chrome at
  once, so the only way the bottom bar can match the sheet AND the top bar
  match the page is for the sheet and the page to agree. Detached panels
  (.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
  never touch the viewport edge, so they still need to read as raised. */
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
  --ex-radius-sm: 8px;
  --ex-radius-md: 14px;
  --ex-radius-lg: 22px;
  --ex-radius-xl: 32px;
  --ex-radius-pill: 999px;
  --ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ex-panel-bg: #0a0a0b;
    --ex-bg-elevated: #1c1c1e;
    --ex-bg-inset: #222225;
    --ex-fg: #f4f4f5;
    --ex-fg-muted: #a1a1aa;
    --ex-fg-faint: #71717a;
    --ex-border: #303033;
    --ex-border-strong: #3f3f46;
    --ex-accent: #9385ff;
    --ex-accent-fg: #0a0a0b;
    --ex-accent-soft: #23204a;
    --ex-danger: #ff7a70;
    --ex-danger-soft: #2c1917;
    --ex-success: #4ade80;
    --ex-success-soft: #12271a;
    --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
    --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
    --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
  }
}

/* An explicit theme choice must beat the media query in BOTH directions.
   A host that offers a theme toggle sets data-theme on the root element,
   and without these two blocks the examples keep following the OS instead:
   a dark-OS visitor who picks light gets a light page around sheets that
   stay dark. The tokens are restated rather than shared because a media
   query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
  --ex-panel-bg: #ffffff;
  --ex-bg-elevated: #ffffff;
  --ex-bg-inset: #f5f5f4;
  --ex-fg: #1c1917;
  --ex-fg-muted: #57534e;
  --ex-fg-faint: #78716c;
  --ex-border: #e7e5e4;
  --ex-border-strong: #d6d3d1;
  --ex-accent: #5a45e8;
  --ex-accent-fg: #ffffff;
  --ex-accent-soft: #efecff;
  --ex-danger: #c4392f;
  --ex-danger-soft: #fbe9e6;
  --ex-success: #15803d;
  --ex-success-soft: #e8f5ec;
  --ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
  --ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
  --ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}

:root[data-theme="dark"] {
  --ex-panel-bg: #0a0a0b;
  --ex-bg-elevated: #1c1c1e;
  --ex-bg-inset: #222225;
  --ex-fg: #f4f4f5;
  --ex-fg-muted: #a1a1aa;
  --ex-fg-faint: #71717a;
  --ex-border: #303033;
  --ex-border-strong: #3f3f46;
  --ex-accent: #9385ff;
  --ex-accent-fg: #0a0a0b;
  --ex-accent-soft: #23204a;
  --ex-danger: #ff7a70;
  --ex-danger-soft: #2c1917;
  --ex-success: #4ade80;
  --ex-success-soft: #12271a;
  --ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
  --ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
  --ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}

/* ── Triggers & generic buttons ─────────────────────────────────────────── */

.ex-trigger {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 20px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  transition: border-color 120ms ease, transform 120ms ease;
}

.ex-trigger:hover {
  border-color: var(--ex-accent);
}

.ex-trigger:active {
  transform: scale(0.97);
}

.ex-btn {
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-radius: var(--ex-radius-sm);
  padding: 11px 16px;
  font-size: 0.92rem;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}

.ex-btn:hover {
  border-color: var(--ex-accent);
}

.ex-btn-danger {
  background: var(--ex-danger);
  border-color: var(--ex-danger);
  color: #fff;
}

.ex-btn:disabled {
  opacity: 0.55;
  cursor: not-allowed;
}

.ex-actions {
  display: flex;
  gap: 10px;
  margin-top: 4px;
}

.ex-actions .ex-btn {
  flex: 1;
  justify-content: center;
}

/* ── Panel content wrappers ──────────────────────────────────────────────
   Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
   inside supplies the padding, since the handle sits outside it. */

.ex-panel {
  background: var(--ex-panel-bg);
  color: var(--ex-fg);
}

.ex-panel-pad {
  padding: 26px;
}

.ex-panel-body h2,
.ex-panel-pad h2 {
  font-size: 1.2rem;
  margin: 6px 0 8px;
}

.ex-panel-pad h2 {
  font-size: 1.1rem;
  margin: 0 0 14px;
}

.ex-panel-body p,
.ex-panel-pad p {
  color: var(--ex-fg-muted);
  line-height: 1.55;
  margin: 0 0 16px;
}

@keyframes ex-wallet-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

/* ── Confirm icon ────────────────────────────────────────────────────────── */

.ex-confirm-icon {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--ex-danger-soft);
  color: var(--ex-danger);
  display: flex;
  align-items: center;
  justify-content: center;
  margin-bottom: 14px;
}

@keyframes ex-spin {
  to {
    transform: rotate(360deg);
  }
}

.ex-ride-stops .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

.ex-page-actions .ex-btn[aria-pressed="true"] {
  border-color: var(--ex-accent);
  color: var(--ex-accent);
}

/* Desktop: present as a centered lightbox card instead of the library's
   default right-docked drawer. Two library levers, no new API:
   --scrollsheet-inset-bottom (already read by measure() to flag the sheet
   data-scrollsheet-detached, which centers it vertically for a single
   detents= {
  ['full']
}

panel for free) plus overriding the dock's left/right/
   width back to a max-width + auto-margin center, the same recipe core.css's
   own top-side desktop rule uses. Only correct for a single ['full'] detent
   with disableDrag, exactly what this example uses — a shorter detent stays
   bottom-anchored inside the floating region instead of centering, since the
   panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
  .ex-lb-panel {
  --scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
  left: var(--scrollsheet-inset-x, 0px);
  right: var(--scrollsheet-inset-x, 0px);
  width: auto;
  max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
  margin-inline: auto;
  }
  /* The card hugs the photo instead of standing full-viewport-tall with
  dead space under it. Doubled class outranks the injected core rule's
  height (same specificity would lose on source order — core.css loads
  after this file). It stays anchored to the floating region's bottom
  edge — the panel's coordinate space is the canvas (viewport plus
  detent runway), so viewport-centering tricks land in the wrong space.
  Safe only because this sheet is single-['full']-detent with
  disableDrag: nothing reads the panel height for snap math. */
  .ex-panel.ex-lb-panel {
  height: fit-content;
  max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
  }
}

/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
   custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
   spring curve, the same shape the sheet's own open/close travel uses,
   instead of a hand-picked cubic-bezier that would only ease at one end.
   The fallback values are the pre-spring cubic-bezier, in case that
   <style> tag is ever missing (JS disabled, or this rule copied out on
   its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
  animation-duration: var(--ex-lb-morph-dur, 280ms);
  animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}

/* Entrance: starts a beat after the photo (30% of its duration) and
   finishes before it (60% long, ending at 90%) — chrome materializes
   around a subject already mid-flight instead of racing it. Exit: no
   delay, under half the duration — chrome should clear fast, not linger
   while the photo shrinks back to its thumbnail. Same spring curve as the
   photo morph on the way in for one consistent character; a plain ease-in
   on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
  from {
  opacity: 0;
  transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-out-top {
  to {
    opacity: 0;
    transform: translateY(-10px);
  }
}

@keyframes ex-lb-bar-in-bottom {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
}

@keyframes ex-lb-bar-out-bottom {
  to {
    opacity: 0;
    transform: translateY(10px);
  }
}

::view-transition-new(ex-lb-topbar) {
  animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-topbar) {
  animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

::view-transition-new(ex-lb-bottombar) {
  animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
  var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}

::view-transition-old(ex-lb-bottombar) {
  animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}

.ex-side-row .ex-btn {
  text-transform: capitalize;
}

/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
  .ex-trigger,
  .ex-btn {
  padding: 8px 14px;
  font-size: 0.88rem;
  }
}