Native dialog. Native scrolling.

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

$bun add scrollsheet

How it stacks up

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

DimensionscrollsheetvaulSilkreact-modal-sheet
Shipped size, gzip20 kB22 kB40 kB52 kB
Runtime dependencies0Radix Dialog0Motion (peer)
Last releaseactiveDec 2024activeactive
ToastsOne package for both. It matches sonner's API, so an existing Toaster keeps working.yes, sonner drop-innoyesno
Lightboxyesnoyesno
Native <dialog>, top layerThe top layer always paints above the page, whatever your z-index. The browser traps focus and blocks clicks behind it.yesnonono
Gesture enginenative scrollpointer eventsnative scrollMotion drag
Browsers without <dialog> (~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.plain modal, no gesturesfull supportfull supportfull support
LicenseMIT, freeMIT, freepaid for the full setMIT, free

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 Jan 2026

    Chrome 144

    Without itNon-modal sheets ignore the back gesture.

Examples

Every preview runs the real package. Open Code on any card for the source.

Media

Photo viewer

No handle, disableDrag, a corner close button, and a full-bleed image.

photo.tsx

import { Sheet } from "scrollsheet";

import { XIcon } from "./icons";

/**
 * Media sheet: no Handle, disableDrag so a pan over the photo can never
 * dismiss it, a close button in the top-right corner as the only exit
 * besides Esc and backdrop. The image fills the panel edge to edge.
 */
export default function PhotoExample() {
  return (
    <Sheet.Root detents={[0.85]} disableDrag>
      <Sheet.Trigger className="ex-trigger">View photo</Sheet.Trigger>
      <Sheet.Content className="ex-panel ex-photo-panel" aria-label="Photo">
        <Sheet.Close className="ex-photo-close" aria-label="Close photo">
          <XIcon />
        </Sheet.Close>
        <svg
          className="ex-photo-image"
          viewBox="0 0 390 720"
          preserveAspectRatio="xMidYMid slice"
          role="img"
          aria-label="Dunes at dusk"
        >
          <defs>
            <linearGradient id="ex-photo-sky" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="#2b2a4a" />
              <stop offset="0.55" stopColor="#8a5a7a" />
              <stop offset="1" stopColor="#e8a06a" />
            </linearGradient>
          </defs>
          <rect width="390" height="720" fill="url(#ex-photo-sky)" />
          <circle cx="195" cy="330" r="52" fill="#f6d8a8" opacity="0.9" />
          <path d="M0 480 Q120 400 240 470 T 390 450 V720 H0 Z" fill="#463a56" />
          <path d="M0 560 Q150 480 300 555 T 390 540 V720 H0 Z" fill="#2e2740" />
          <path d="M0 640 Q180 580 390 630 V720 H0 Z" fill="#1d1930" />
        </svg>
        <div className="ex-photo-caption">
          <div className="ex-row-title">Dunes at dusk</div>
          <div className="ex-row-sub">Merzouga, Morocco</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 {
  --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-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-bg-elevated);
  color: var(--ex-fg);
}

.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;
  }
}

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

/* ── Photo (media sheet: no handle, no drag, full-bleed image) ──────────── */

.ex-photo-panel {
  display: flex;
  flex-direction: column;
  padding: 0;
  --scrollsheet-safe-area: 0px;
}

.ex-photo-image {
  flex: 1;
  width: 100%;
  min-height: 0;
  display: block;
}

.ex-photo-close {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border: none;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.45);
  color: #fff;
  font-size: 1rem;
  cursor: pointer;
  backdrop-filter: blur(6px);
}

.ex-photo-close:hover {
  background: rgb(0 0 0 / 0.6);
}

.ex-photo-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 18px 20px calc(18px + env(safe-area-inset-bottom, 0px));
  background: linear-gradient(rgb(0 0 0 / 0), rgb(0 0 0 / 0.55));
  color: #fff;
}

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

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

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 XIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><path d="M18 6L6 18M6 6l12 12"/></Icon>;
}

Media

Music player

A modal={false} mini player docked low, tap to expand to the full player.

music-player.tsx

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

import { ChevronDownIcon, PauseIcon, PlayIcon, SkipBackIcon, SkipForwardIcon } from "./icons";

type ArtVariant = "midnight" | "coastline" | "paper-boats" | "amber-hour";

interface Track {
  title: string;
  artist: string;
  album: string;
  duration: string;
  variant: ArtVariant;
}

interface TrackArtProps {
  variant: ArtVariant;
  className?: string;
}

const TRACKS: Track[] = [
  {
    title: "Midnight Static",
    artist: "Rosa Alves",
    album: "Coastline EP",
    duration: "3:42",
    variant: "midnight",
  },
  {
    title: "Coastline, Slow",
    artist: "Rosa Alves",
    album: "Coastline EP",
    duration: "4:05",
    variant: "coastline",
  },
  {
    title: "Paper Boats",
    artist: "Theo Marsh",
    album: "Harbor Sketches",
    duration: "2:58",
    variant: "paper-boats",
  },
  {
    title: "Amber Hour",
    artist: "Nadia Cole",
    album: "Low Light",
    duration: "3:21",
    variant: "amber-hour",
  },
];

/*
 * The docked stop is a fixed height, not 'content'. This sheet swaps its
 * body between the mini bar and the full player based on which detent is
 * active, so a 'content' stop would measure the full player once expanded,
 * collapse to the same height as 'full', and leave nothing to drag back
 * down to. The value matches .ex-player-mini's own height exactly, so the
 * docked slice is all row and no bare panel.
 */
const DETENTS: DetentSpec[] = ["68px", "full"];
const START_ELAPSED_SECONDS = 84;

function parseDuration(value: string): number {
  const [minutes, seconds] = value.split(":");
  return Number(minutes ?? 0) * 60 + Number(seconds ?? 0);
}

function formatTime(totalSeconds: number): string {
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = Math.floor(totalSeconds % 60);
  return `${minutes}:${String(seconds).padStart(2, "0")}`;
}

/**
 * Inline album-art scenes, one per track — same house style as photo.tsx's
 * dune (a gradient field plus a couple of layered path silhouettes), sized
 * to fill a square box at any scale from a 34px queue thumbnail up to the
 * full-width player art.
 */
function TrackArt({ variant, className }: TrackArtProps) {
  switch (variant) {
    case "coastline":
      return (
        <svg className={className} viewBox="0 0 64 64" aria-hidden="true">
          <defs>
            <linearGradient id="ex-art-coastline" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="#372a4d" />
              <stop offset="0.55" stopColor="#b5657a" />
              <stop offset="1" stopColor="#eda468" />
            </linearGradient>
          </defs>
          <rect width="64" height="64" fill="url(#ex-art-coastline)" />
          <circle cx="45" cy="19" r="7" fill="#ffe4b3" opacity="0.9" />
          <path d="M0 38 Q16 30 32 37 T64 35 V64 H0 Z" fill="#7c4d5d" />
          <path d="M0 48 Q18 42 36 48 T64 46 V64 H0 Z" fill="#4a3049" />
        </svg>
      );
    case "paper-boats":
      return (
        <svg className={className} viewBox="0 0 64 64" aria-hidden="true">
          <defs>
            <linearGradient id="ex-art-paper-boats" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="#264653" />
              <stop offset="1" stopColor="#5aa9a3" />
            </linearGradient>
          </defs>
          <rect width="64" height="64" fill="url(#ex-art-paper-boats)" />
          <path
            d="M0 44 Q16 39 32 44 T64 42"
            stroke="#cdeceb"
            strokeWidth="2"
            fill="none"
            opacity="0.55"
          />
          <path
            d="M0 52 Q16 48 32 52 T64 50"
            stroke="#a9d8d4"
            strokeWidth="2"
            fill="none"
            opacity="0.4"
          />
          <path d="M32 18 L44 32 L32 39 L20 32 Z" fill="#f4f1e8" />
          <line x1="32" y1="18" x2="32" y2="39" stroke="#264653" strokeWidth="1" />
        </svg>
      );
    case "amber-hour":
      return (
        <svg className={className} viewBox="0 0 64 64" aria-hidden="true">
          <defs>
            <linearGradient id="ex-art-amber-hour" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="#7a3b2e" />
              <stop offset="0.5" stopColor="#d97b3f" />
              <stop offset="1" stopColor="#f4c467" />
            </linearGradient>
          </defs>
          <rect width="64" height="64" fill="url(#ex-art-amber-hour)" />
          <circle cx="32" cy="30" r="13" fill="#ffe9b8" opacity="0.92" />
          <path d="M0 42 Q22 36 44 41 T64 40 V64 H0 Z" fill="#5c2a22" opacity="0.85" />
        </svg>
      );
    default:
      return (
        <svg className={className} viewBox="0 0 64 64" aria-hidden="true">
          <defs>
            <linearGradient id="ex-art-midnight" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="#181530" />
              <stop offset="0.6" stopColor="#332a5c" />
              <stop offset="1" stopColor="#5c4a9e" />
            </linearGradient>
          </defs>
          <rect width="64" height="64" fill="url(#ex-art-midnight)" />
          <circle cx="50" cy="14" r="3.4" fill="#e8dcff" opacity="0.85" />
          <g stroke="#cabdfa" strokeWidth="1.4" opacity="0.5">
            <line x1="10" y1="14" x2="14" y2="14" />
            <line x1="22" y1="10" x2="25" y2="10" />
            <line x1="36" y1="18" x2="40" y2="18" />
            <line x1="16" y1="24" x2="20" y2="24" />
          </g>
          <path
            d="M0 40 Q8 32 16 40 T32 40 T48 40 T64 40"
            stroke="#a695ef"
            strokeWidth="2"
            fill="none"
            opacity="0.6"
          />
          <path d="M0 52 Q16 44 32 51 T64 49 V64 H0 Z" fill="#241f42" />
        </svg>
      );
  }
}

/**
 * modal={false}: the mini player docks at a low detent while the rest of the
 * page stays scrollable and clickable underneath it. The handle's built-in
 * click-to-cycle toggles between the docked mini bar and the full player, no
 * extra state wiring needed for that part.
 */
export default function MusicPlayerExample() {
  const [active, setActive] = React.useState<DetentSpec>(DETENTS[0]!);
  const expanded = active === "full";
  const [currentIndex, setCurrentIndex] = React.useState(0);
  const [isPlaying, setIsPlaying] = React.useState(true);
  const [elapsed, setElapsed] = React.useState(START_ELAPSED_SECONDS);

  const currentTrack = TRACKS[currentIndex]!;
  const totalSeconds = parseDuration(currentTrack.duration);
  const progress = Math.min(100, (elapsed / totalSeconds) * 100);

  const upNextIndexes = React.useMemo(
    () => Array.from({ length: 3 }, (_, i) => (currentIndex + i + 1) % TRACKS.length),
    [currentIndex],
  );

  // Demo-only playhead: ticks while "playing" so the progress bar and
  // elapsed time read as live, no audio element or new dependency involved.
  React.useEffect(() => {
    if (!isPlaying) return;
    const id = window.setInterval(() => {
      setElapsed((seconds) => (seconds + 1 >= totalSeconds ? totalSeconds : seconds + 1));
    }, 1000);
    return () => window.clearInterval(id);
  }, [isPlaying, totalSeconds]);

  const selectTrack = (index: number) => {
    setCurrentIndex(index);
    setElapsed(0);
    setIsPlaying(true);
  };

  const skip = (direction: 1 | -1) => {
    selectTrack((currentIndex + direction + TRACKS.length) % TRACKS.length);
  };

  return (
    <Sheet.Root
      modal={false}
      detents={DETENTS}
      activeDetent={active}
      onActiveDetentChange={setActive}
    >
      <Sheet.Trigger className="ex-trigger">Show player</Sheet.Trigger>
      <Sheet.Content className="ex-panel ex-player-panel" aria-label="Now playing">
        {/* No handle on the docked mini bar: it is a persistent player you tap
            to expand, not a sheet you grab. The handle only appears once
            expanded, to pull the full player back down. */}
        {expanded && <Sheet.Handle />}
        {expanded ? (
          <div className="ex-panel-body">
            {/* The handle alone is a thin target and reads as decoration, so
                the expanded player also carries the explicit collapse control
                every real one has. Setting the detent directly, rather than
                closing, is what sends it back to the docked bar. It sits
                beside the artwork rather than inside it: the artwork is
                aria-hidden, and a button nested in that subtree would be
                hidden from assistive tech too. */}
            <div className="ex-player-art">
              <div className="ex-album-art ex-album-art-lg" aria-hidden="true">
                <TrackArt variant={currentTrack.variant} className="ex-track-art" />
              </div>
              <button
                type="button"
                className="ex-player-collapse"
                onClick={() => setActive(DETENTS[0]!)}
                aria-label="Collapse to mini player"
              >
                <ChevronDownIcon />
              </button>
            </div>
            <Sheet.Title>{currentTrack.title}</Sheet.Title>
            <Sheet.Description>
              {currentTrack.artist}, {currentTrack.album}
            </Sheet.Description>
            <div className="ex-progress-track">
              <div className="ex-progress-fill" style={{ width: `${progress}%` }} />
            </div>
            <div className="ex-progress-times">
              <span>{formatTime(elapsed)}</span>
              <span>{currentTrack.duration}</span>
            </div>
            <div className="ex-transport">
              <button
                type="button"
                className="ex-transport-btn"
                aria-label="Previous track"
                onClick={() => skip(-1)}
              >
                <SkipBackIcon />
              </button>
              <button
                type="button"
                className="ex-play-btn"
                aria-label={isPlaying ? "Pause" : "Play"}
                onClick={() => setIsPlaying((playing) => !playing)}
              >
                {isPlaying ? <PauseIcon /> : <PlayIcon />}
              </button>
              <button
                type="button"
                className="ex-transport-btn"
                aria-label="Next track"
                onClick={() => skip(1)}
              >
                <SkipForwardIcon />
              </button>
            </div>
            <div className="ex-up-next-label">Up next</div>
            <div className="ex-track-list">
              {upNextIndexes.map((index) => {
                const track = TRACKS[index]!;
                return (
                  <button
                    type="button"
                    className="ex-track"
                    key={track.title}
                    onClick={() => selectTrack(index)}
                  >
                    <span className="ex-track-thumb" aria-hidden="true">
                      <TrackArt variant={track.variant} className="ex-track-art" />
                    </span>
                    <div className="ex-track-body">
                      <div className="ex-track-name">{track.title}</div>
                      <div className="ex-track-artist">{track.artist}</div>
                    </div>
                    <span className="ex-track-duration">{track.duration}</span>
                  </button>
                );
              })}
            </div>
          </div>
        ) : (
          <div className="ex-player-mini">
            {/* Two buttons, not one: a play control that really toggles
                play/pause can't live inside the row's own expand button
                (buttons can't nest), so the expand tap target and the play
                control are siblings instead of nested interactive elements. */}
            <button
              type="button"
              className="ex-player-mini-tap"
              onClick={() => setActive("full")}
              aria-label={`Expand player, ${currentTrack.title} by ${currentTrack.artist}`}
            >
              <span className="ex-album-art" aria-hidden="true">
                <TrackArt variant={currentTrack.variant} className="ex-track-art" />
              </span>
              <span className="ex-player-mini-info">
                <span className="ex-player-mini-title">{currentTrack.title}</span>
                <span className="ex-player-mini-artist">{currentTrack.artist}</span>
              </span>
            </button>
            <button
              type="button"
              className="ex-play-icon"
              aria-label={isPlaying ? "Pause" : "Play"}
              onClick={(event) => {
                event.stopPropagation();
                setIsPlaying((playing) => !playing);
              }}
            >
              {isPlaying ? <PauseIcon /> : <PlayIcon />}
            </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 {
  --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-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-bg-elevated);
  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;
  }
}

/* ── Music player ────────────────────────────────────────────────────────── */

.ex-player-mini {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  appearance: none;
  border: none;
  background: transparent;
  color: var(--ex-fg);
  padding: 10px 20px 18px;
  cursor: pointer;
  font-family: inherit;
  text-align: left;
}

.ex-album-art {
  flex: none;
  width: 44px;
  height: 44px;
  border-radius: var(--ex-radius-sm);
  background: linear-gradient(135deg, var(--ex-accent), #ff8fb3);
  display: flex;
  align-items: center;
  justify-content: center;
  color: #fff;
  font-size: 1.1rem;
}

.ex-album-art-lg {
  width: 100%;
  aspect-ratio: 1;
  border-radius: var(--ex-radius-md);
  font-size: 3rem;
  margin-bottom: 18px;
}

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

.ex-player-mini-title {
  font-weight: 600;
  font-size: 0.92rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ex-player-mini-artist {
  color: var(--ex-fg-muted);
  font-size: 0.8rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.ex-play-icon {
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--ex-accent);
  color: var(--ex-accent-fg);
  display: flex;
  align-items: center;
  justify-content: center;
}

.ex-progress-track {
  height: 4px;
  border-radius: var(--ex-radius-pill);
  background: var(--ex-border);
  margin-bottom: 18px;
  overflow: hidden;
}

.ex-progress-fill {
  height: 100%;
  background: var(--ex-accent);
  border-radius: inherit;
}

.ex-transport {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 22px;
  margin-bottom: 22px;
}

.ex-transport-btn {
  appearance: none;
  border: none;
  background: transparent;
  color: var(--ex-fg);
  cursor: pointer;
  padding: 6px;
}

.ex-play-btn {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  background: var(--ex-accent);
  color: var(--ex-accent-fg);
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}

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

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

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

.ex-track-name {
  font-weight: 600;
}

.ex-track.is-playing .ex-track-name {
  color: var(--ex-accent);
}

.ex-track-artist {
  color: var(--ex-fg-muted);
  font-size: 0.82rem;
}

.ex-track-duration {
  color: var(--ex-fg-faint);
  font-size: 0.82rem;
  font-family: var(--ex-font-mono);
}

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

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

/* ── rich: music ─────────────────────────────────────────────────────── */

/* Full player: the base section's .ex-album-art was a flat gradient behind
   a glyph; TrackArt now fills it edge to edge, so it needs to clip to the
   existing radius instead of just centering an icon inside it. */
.ex-album-art {
  overflow: hidden;
}

/* The base section pins .ex-album-art to 44x44 — with height still set,
   the -lg variant's aspect-ratio never engages and the art collapses to a
   full-width 44px strip. Height must go back to auto for the square to
   come from aspect-ratio. */
.ex-album-art-lg {
  height: auto;
}

.ex-track-art {
  display: block;
  width: 100%;
  height: 100%;
}

/* Elapsed/total readout under the bar. Overriding the base section's 18px
   .ex-progress-track margin here (rather than fighting it with a negative
   margin) keeps the times snug against the bar, with the times row itself
   carrying the breathing room before the transport controls. */
.ex-progress-track {
  margin-bottom: 6px;
}

.ex-progress-times {
  display: flex;
  justify-content: space-between;
  margin-bottom: 20px;
  color: var(--ex-fg-faint);
  font-size: 0.78rem;
  font-family: var(--ex-font-mono);
}

.ex-up-next-label {
  margin-bottom: 8px;
  color: var(--ex-fg-faint);
  font-size: 0.76rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

/* Up-next rows: the base .ex-track was a plain div; it is a button now
   (tap to make it the current track), so it needs the usual button reset
   plus a slot for the new thumbnail in place of the old index number. */
.ex-track-list .ex-track {
  width: 100%;
  appearance: none;
  border: none;
  background: transparent;
  font-family: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.ex-track-list .ex-track:hover .ex-track-name {
  color: var(--ex-accent);
}

.ex-track-thumb {
  display: block;
  flex: none;
  width: 34px;
  height: 34px;
  border-radius: var(--ex-radius-sm);
  overflow: hidden;
}

/* Docked mini bar. 12px + the 44px album art + 12px is 68px, which is
   exactly the docked detent in music-player.tsx: keep the two in step or a
   band of bare panel shows under the row. The detent cannot be 'content'
   here, because this sheet swaps its body for the full player once
   expanded and 'content' would then resolve to the same height as 'full',
   leaving nothing to drag back down to. */
.ex-player-mini {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 18px;
}

/* A docked player bar is not a floating card, so it opts out of the
   desktop margin every bottom sheet gets at >=768px and stays flush to the
   viewport edge. Zeroing the safe-area pad keeps the docked slice exactly
   the detent height on a notched device, where the panel's own bottom
   padding would otherwise eat into the row. */
.ex-player-panel {
  --scrollsheet-desktop-margin: 0px;
  --scrollsheet-safe-area: 0px;
}

/* Two buttons, not one: a play control that really toggles play/pause
   can't sit inside the row's own expand button (buttons can't nest), so
   the expand tap target and the play control are siblings here. */
.ex-player-mini-tap {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 1;
  min-width: 0;
  appearance: none;
  border: none;
  background: transparent;
  color: inherit;
  padding: 0;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
}

/* Title and artist are inline <span>s inside the tap button — without a
   column here they run together on one line. */
.ex-player-mini-info {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.ex-play-icon {
  appearance: none;
  border: none;
  cursor: pointer;
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

/* Collapse control on the expanded player. Sits over the artwork's top-left
   the way a real player's dismiss chevron does, with a 44px target. The
   wrapper is what makes the offsets land on the artwork's own corner. */
.ex-player-art {
  position: relative;
}

.ex-player-collapse {
  position: absolute;
  top: 12px;
  left: 12px;
  z-index: 1;
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 999px;
  background: rgb(0 0 0 / 0.45);
  color: #fff;
  cursor: pointer;
}

.ex-player-collapse::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

.ex-player-collapse svg {
  width: 20px;
  height: 20px;
}

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 ChevronDownIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><path d="m6 9 6 6 6-6"/></Icon>;
}

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

export function PlayIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/></Icon>;
}

export function SkipBackIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><path d="M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432zM3 20V4"/></Icon>;
}

export function SkipForwardIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><path d="M21 4v16M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z"/></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");
      }}
    >
      <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 {
  --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-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-bg-elevated);
  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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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>
      <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 {
  --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-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-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;
}

@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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

Navigation

Notifications

side="right": the mirror image of the sidebar, a notifications drawer.

notifications.tsx

import { Sheet } from "scrollsheet";

interface Notification {
  initials: string;
  title: string;
  body: string;
  time: string;
}

const NOTIFICATIONS: Notification[] = [
  {
    initials: "PN",
    title: "Priya Nair commented",
    body: "Can we ship this behind a flag first?",
    time: "2m",
  },
  { initials: "CI", title: "Deploy finished", body: "main, 47s build", time: "18m" },
  { initials: "RS", title: "Rahul Sen mentioned you", body: "in #scrollsheet-launch", time: "1h" },
  { initials: "OK", title: "3 checks passed", body: "typecheck, unit, e2e", time: "3h" },
];

/**
 * side="right": the same primitives as sidebar.tsx's side="left",
 * anchored to the opposite edge. No Sheet.Handle here either, the panel
 * itself still drags and dismisses.
 */
export default function NotificationsExample() {
  return (
    <Sheet.Root side="right" detents={["340px"]}>
      <Sheet.Trigger className="ex-trigger">Notifications</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Notifications">
        <div className="ex-panel-pad">
          <Sheet.Title>Notifications</Sheet.Title>
          {NOTIFICATIONS.map((n) => (
            <div className="ex-row" key={n.title}>
              <div className="ex-avatar">{n.initials}</div>
              <div className="ex-row-body">
                <div className="ex-row-title">{n.title}</div>
                <div className="ex-row-sub">{n.body}</div>
              </div>
              <div className="ex-row-sub">{n.time}</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 {
  --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-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-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;
}

/* ── 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;
}

.ex-avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--ex-accent-soft);
  color: var(--ex-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.85rem;
  flex: none;
}

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

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

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

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

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

Navigation

Page transition

detents={["full"]} plus backgroundEffect: a sheet as an iOS-style page push.

Upcoming trip
Jodhpur, 3 nights, departs Friday

page-transition.tsx

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

import { MapPinIcon } from "./icons";

const ITINERARY = [
  "Mehrangarh Fort",
  "Toorji Ka Jhalra",
  "Clock Tower market",
  "Bishnoi village drive",
  "Rooftop dinner at Indique",
  "Sunset at Rao Jodha park",
];

/**
 * Full-detent sheet as a page push: backgroundEffect scales (or parallaxes)
 * the element behind it, the iOS navigation feel without a router.
 *
 * backgroundRef, not the data-scrollsheet-background attribute. The
 * attribute resolves document-wide and first match wins, so on any page
 * that already marks its own shell (this docs site marks its layout) the
 * effect lands on that shell instead of on this component, and nothing
 * appears to happen here. A ref names the element directly and cannot be
 * shadowed, which is what you want for anything that ships inside a larger
 * page. Mark the shell with the attribute when the whole page really is
 * what should push back.
 *
 * Defaults to parallax, since the shift reads more clearly against a hero
 * image than the scale does; scale stays one tap away.
 */
export default function PageTransitionExample() {
  const [effect, setEffect] = React.useState<"scale" | "parallax">("parallax");
  const pageRef = React.useRef<HTMLDivElement>(null);

  return (
    <div className="ex-page-wrap" ref={pageRef}>
      <div className="ex-page-mock">
        <div className="ex-row-title">Upcoming trip</div>
        <div className="ex-row-sub">Jodhpur, 3 nights, departs Friday</div>
        <div className="ex-page-actions">
          {(["scale", "parallax"] as const).map((mode) => (
            <button
              key={mode}
              type="button"
              className="ex-btn"
              aria-pressed={effect === mode}
              onClick={() => setEffect(mode)}
            >
              {mode}
            </button>
          ))}
        </div>
      </div>
      <Sheet.Root detents={["full"]} backgroundEffect={effect} backgroundRef={pageRef}>
        <Sheet.Trigger className="ex-trigger">Open trip details</Sheet.Trigger>
        <Sheet.Content className="ex-panel" aria-label="Trip details">
          <Sheet.Handle />
          <div className="ex-page-hero">
            <svg
              className="ex-page-hero-image"
              viewBox="0 0 390 240"
              preserveAspectRatio="xMidYMid slice"
              role="img"
              aria-label="Blue-washed rooftops of Jodhpur below Mehrangarh Fort at dusk"
            >
              <defs>
                <linearGradient id="ex-page-sky" x1="0" y1="0" x2="0" y2="1">
                  <stop offset="0" stopColor="#1c2a4e" />
                  <stop offset="0.55" stopColor="#6b76a8" />
                  <stop offset="1" stopColor="#eaa969" />
                </linearGradient>
              </defs>
              <rect width="390" height="240" fill="url(#ex-page-sky)" />
              <circle cx="206" cy="112" r="26" fill="#ffdca0" opacity="0.85" />
              <path
                d="M242 54 q6 -8 12 0 q6 -8 12 0"
                stroke="#2a2340"
                strokeWidth="2"
                fill="none"
                strokeLinecap="round"
                opacity="0.6"
              />
              <path
                d="M96 74 q5 -7 10 0 q5 -7 10 0"
                stroke="#2a2340"
                strokeWidth="2"
                fill="none"
                strokeLinecap="round"
                opacity="0.5"
              />
              <path
                d="M0 178 Q40 158 80 163 Q130 170 150 128 Q170 93 200 88 Q230 93 245 128 Q260 165 310 158 Q350 156 390 170 V195 H0 Z"
                fill="#362c52"
              />
              <rect x="172" y="78" width="10" height="18" fill="#362c52" />
              <rect x="186" y="70" width="12" height="26" fill="#362c52" />
              <rect x="202" y="66" width="14" height="30" fill="#362c52" />
              <rect x="220" y="74" width="10" height="22" fill="#362c52" />
              <g fill="#4d6291">
                <rect x="0" y="186" width="46" height="54" />
                <rect x="54" y="196" width="30" height="44" />
                <rect x="92" y="190" width="52" height="50" />
                <rect x="150" y="200" width="34" height="40" />
                <rect x="192" y="204" width="30" height="36" />
                <rect x="228" y="192" width="46" height="48" />
                <rect x="280" y="198" width="34" height="42" />
                <rect x="320" y="188" width="40" height="52" />
                <rect x="364" y="194" width="26" height="46" />
              </g>
              <g fill="#33507e">
                <rect x="-10" y="200" width="50" height="40" />
                <rect x="40" y="192" width="36" height="48" />
                <rect x="76" y="206" width="30" height="34" />
                <rect x="106" y="196" width="54" height="44" />
                <rect x="160" y="210" width="28" height="30" />
                <rect x="188" y="204" width="34" height="36" />
                <rect x="222" y="194" width="46" height="46" />
                <rect x="268" y="208" width="32" height="32" />
                <rect x="300" y="190" width="50" height="50" />
                <rect x="350" y="200" width="40" height="40" />
              </g>
              <path d="M64 208 a10 8 0 0 1 20 0 Z" fill="#33507e" />
              <path d="M296 190 a11 9 0 0 1 22 0 Z" fill="#33507e" />
              <g fill="#22385f">
                <rect x="-10" y="214" width="70" height="30" />
                <rect x="64" y="208" width="60" height="36" />
                <rect x="128" y="218" width="80" height="26" />
                <rect x="214" y="210" width="66" height="34" />
                <rect x="284" y="216" width="70" height="28" />
                <rect x="356" y="206" width="40" height="38" />
              </g>
              <g fill="#ffdb9e" opacity="0.85">
                <rect x="14" y="200" width="4" height="6" />
                <rect x="66" y="212" width="4" height="6" />
                <rect x="105" y="204" width="4" height="6" />
                <rect x="164" y="216" width="4" height="6" />
                <rect x="236" y="206" width="4" height="6" />
                <rect x="290" y="200" width="4" height="6" />
                <rect x="332" y="212" width="4" height="6" />
                <rect x="374" y="208" width="4" height="6" />
              </g>
            </svg>
            <div className="ex-page-hero-caption">
              <Sheet.Title>Jodhpur</Sheet.Title>
              <Sheet.Description>The blue city, three nights at Raas.</Sheet.Description>
            </div>
          </div>
          <div className="ex-panel-body">
            <div className="ex-facts">
              <div className="ex-facts-col">
                <div className="ex-facts-label">Best season</div>
                <div className="ex-facts-value">
                  October to March, once the heat breaks and mornings turn cool.
                </div>
              </div>
              <div className="ex-facts-col">
                <div className="ex-facts-label">Getting there</div>
                <div className="ex-facts-value">
                  Fly into Jodhpur, or take the overnight train from Delhi.
                </div>
              </div>
            </div>
            <div className="ex-ride-route">
              {ITINERARY.map((stop) => (
                <div className="ex-ride-stop" key={stop}>
                  <span className="ex-ride-pin" aria-hidden="true">
                    <MapPinIcon />
                  </span>
                  {stop}
                </div>
              ))}
            </div>
            <Sheet.Close className="ex-btn ex-btn-primary">Back</Sheet.Close>
          </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 {
  --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-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: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-bg-elevated);
  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-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);
  }
}

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

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

.ex-ride-route {
  display: grid;
  gap: 10px;
  margin-bottom: 16px;
}

.ex-ride-stop {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  background: var(--ex-bg-inset);
  border-radius: var(--ex-radius-md);
  font-weight: 550;
}

.ex-ride-pin {
  display: grid;
  place-items: center;
  color: var(--ex-accent);
}

/* ── Page transition (backgroundEffect showcase) ────────────────────────── */

.ex-page-wrap {
  border-radius: var(--ex-radius-lg);
  overflow: hidden;
  /* The rounded clip exists for the recede effect; at rest it was shaving
  the trigger's corners. Inset the content clear of the corner curves
  without growing the footprint. */
  padding: 8px;
  margin: -8px;
}

.ex-page-mock {
  padding: 16px;
  background: var(--ex-bg-inset);
  border-radius: var(--ex-radius-lg);
  margin-bottom: 12px;
}

.ex-page-actions {
  display: flex;
  gap: 8px;
  margin-top: 12px;
}

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

/* ── rich: page-transition ─────────────────────────────────────────────────────── */

.ex-page-hero {
  position: relative;
}

.ex-page-hero-image {
  display: block;
  width: 100%;
  height: 220px;
}

.ex-page-hero-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 18px 22px 20px;
  background: linear-gradient(rgb(0 0 0 / 0), rgb(0 0 0 / 0.6));
  color: #fff;
}

.ex-page-hero-caption h2 {
  margin: 0 0 4px;
  font-size: 1.3rem;
}

.ex-page-hero-caption p {
  margin: 0;
  color: rgb(255 255 255 / 0.8);
  font-size: 0.88rem;
}

.ex-facts {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
  margin: 16px 0 20px;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--ex-border);
}

.ex-facts-col {
  min-width: 0;
}

.ex-facts-label {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ex-fg-faint);
  margin-bottom: 6px;
}

.ex-facts-value {
  font-size: 0.86rem;
  color: var(--ex-fg-muted);
  line-height: 1.45;
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

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 MapPinIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><g ><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></g></Icon>;
}

Navigation

Responsive

A sheet under 720px, a centered dialog above it, disableDrag on desktop.

responsive.tsx

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

function useIsDesktop(): boolean {
  const query = "(min-width: 720px)";
  const [isDesktop, setIsDesktop] = React.useState(
    () => typeof window !== "undefined" && window.matchMedia(query).matches,
  );

  React.useEffect(() => {
    const mql = window.matchMedia(query);
    const onChange = () => setIsDesktop(mql.matches);
    onChange();
    mql.addEventListener("change", onChange);
    return () => mql.removeEventListener("change", onChange);
  }, []);

  return isDesktop;
}

/**
 * Sheet under 720px, centered dialog above it, driven from one matchMedia
 * hook plus a media query on the panel's own className: the sheet mechanics
 * (drag, snap, focus trap) stay identical in both layouts, only the CSS
 * (max-width, margin, radius) and the detent list change.
 */
export default function ResponsiveExample() {
  const isDesktop = useIsDesktop();

  return (
    <Sheet.Root detents={isDesktop ? ["content"] : ["content", "full"]} disableDrag={isDesktop}>
      <Sheet.Trigger className="ex-trigger">Invite teammate</Sheet.Trigger>
      <Sheet.Content className="ex-panel ex-panel-responsive" aria-label="Invite teammate">
        {!isDesktop && <Sheet.Handle />}
        <div className="ex-panel-pad">
          <Sheet.Title>Invite a teammate</Sheet.Title>
          <Sheet.Description>
            {isDesktop
              ? "Viewport is 720px or wider: this renders as a centered dialog card, same component."
              : "Viewport is under 720px: this renders as a bottom sheet, same component."}
          </Sheet.Description>
          <div className="ex-field">
            <label className="ex-label" htmlFor="ex-invite-email">
              Email address
            </label>
            <input
              id="ex-invite-email"
              type="email"
              className="ex-input"
              placeholder="[email protected]"
            />
          </div>
          <Sheet.Close className="ex-btn ex-btn-primary ex-btn-block" data-scrollsheet-no-drag>
            Send invite
          </Sheet.Close>
        </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 {
  --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-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-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;
}

/* ── 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-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;
  }
}

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

/* ── Responsive recipe ──────────────────────────────────────────────────── */

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

@media (min-width: 720px) {
  .ex-panel-responsive {
    max-width: 420px;
    margin: 0 auto;
    border-radius: var(--ex-radius-lg);
    box-shadow: var(--ex-shadow-lg);
  }
}

.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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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}
    >
      <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 {
  --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-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-bg-elevated);
  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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

Content and forms

Comments

30 comments, scrollbar="overlay" for the panel's own auto-hiding thumb.

comments.tsx

import { Sheet } from "scrollsheet";

import { ReplyIcon, StarIcon, TrendingUpIcon } from "./icons";

interface Comment {
  name: string;
  time: string;
  text: string;
  likes: number;
  /** Abstract inline-SVG attachment, rendered like examples/photo.tsx. */
  photo?: { label: string; sub: string };
  /** One level deep, so a reply never renders a reply of its own. */
  replies?: Comment[];
}

const COMMENTS: Comment[] = [
  {
    name: "Rosa Alves",
    time: "2m",
    text: "This trail is gorgeous, adding it to my list for next weekend.",
    likes: 14,
    replies: [
      {
        name: "Theo Marsh",
        time: "1m",
        text: "Same here, trying to beat the weekend crowds to the overlook.",
        likes: 3,
      },
    ],
  },
  {
    name: "Nadia Cole",
    time: "11m",
    text: "Caught this right at the top, worth every step of the last mile.",
    likes: 22,
    photo: { label: "Ridge line at first light", sub: "Elevation 2,340m" },
  },
  {
    name: "Kofi Mensah",
    time: "14m",
    text: "Is the trailhead parking lot usually full by mid morning?",
    likes: 2,
  },
  {
    name: "Jia Chen",
    time: "19m",
    text: "That ridge line view at the top is unreal in person.",
    likes: 9,
  },
  {
    name: "Sam Whitfield",
    time: "23m",
    text: "Did you need poles for the last mile or was it manageable without?",
    likes: 1,
  },
  {
    name: "Priya Nair",
    time: "31m",
    text: "Saving this for our anniversary trip, it looks perfect.",
    likes: 11,
  },
  {
    name: "Marco Rossi",
    time: "38m",
    text: "The light in the third shot is doing a lot of work, well earned.",
    likes: 6,
  },
  {
    name: "Hana Suzuki",
    time: "44m",
    text: "How long did the full loop take you, roughly?",
    likes: 0,
  },
  {
    name: "Omar Farouk",
    time: "52m",
    text: "We tried this last month and got turned back by the weather, jealous of the clear skies.",
    likes: 8,
  },
  {
    name: "Lena Bauer",
    time: "1h",
    text: "Adding a proper pack list now, this convinced me.",
    likes: 4,
  },
  {
    name: "Viktor Sokolov",
    time: "1h",
    text: "Underrated trail, glad it's finally getting some attention.",
    likes: 17,
  },
  {
    name: "Uma Iyer",
    time: "1h",
    text: "Any wildlife sightings? Curious if it's worth bringing the long lens.",
    likes: 3,
  },
  {
    name: "Ben Ortiz",
    time: "2h",
    text: "That switchback section looks brutal but so worth it.",
    likes: 5,
  },
  {
    name: "Grace Kim",
    time: "2h",
    text: "Bookmarking for a fall trip when the colors turn.",
    likes: 7,
  },
  {
    name: "Chidi Okafor",
    time: "2h",
    text: "This is exactly the kind of route I've been looking for, thank you.",
    likes: 12,
  },
  {
    name: "Fatima Haddad",
    time: "3h",
    text: "Did the creek crossing near the halfway point still have water?",
    likes: 1,
  },
  {
    name: "Ivo Novak",
    time: "3h",
    text: "The elevation gain graph you posted was really helpful for planning.",
    likes: 9,
  },
  {
    name: "Wren Delacroix",
    time: "3h",
    text: "Six out of five stars for that summit shot alone.",
    likes: 26,
  },
  {
    name: "Yara Haddad",
    time: "4h",
    text: "How early did you start to catch that light?",
    likes: 2,
  },
  {
    name: "Zane Ahmadi",
    time: "4h",
    text: "Doing this with the kids next month, appreciate the trail notes.",
    likes: 6,
  },
  {
    name: "Dana Lindqvist",
    time: "5h",
    text: "The switch to gravel near the ridge caught me off guard last time, good to see it flagged.",
    likes: 4,
  },
  {
    name: "Emre Kaya",
    time: "6h",
    text: "This might be my new favorite loop in the area.",
    likes: 13,
  },
  {
    name: "Tara Singh",
    time: "7h",
    text: "Perfect timing, was just looking for a route for Saturday.",
    likes: 3,
  },
  {
    name: "Quinn Walsh",
    time: "8h",
    text: "The last stretch before the overlook is no joke, worth the burn though.",
    likes: 10,
  },
  {
    name: "Asha Rao",
    time: "9h",
    text: "Love seeing this trail get some love, it deserves it.",
    likes: 15,
  },
  {
    name: "Rosa Alves",
    time: "11h",
    text: "Following up, we did the loop yesterday and it was even better in person.",
    likes: 19,
  },
  {
    name: "Kofi Mensah",
    time: "13h",
    text: "Parking filled up by 8am for us, worth arriving early.",
    likes: 5,
  },
  {
    name: "Nadia Cole",
    time: "1d",
    text: "Printed your notes and brought them along, made the whole day easier.",
    likes: 8,
  },
  {
    name: "Theo Marsh",
    time: "1d",
    text: "Already planning the return trip for next season.",
    likes: 4,
  },
];

function initials(name: string) {
  return name
    .split(" ")
    .map((p) => p[0])
    .join("");
}

function CommentActions({ comment }: { comment: Comment }) {
  return (
    <div className="ex-comment-actions">
      <button type="button" className="ex-comment-action" aria-label={`Like, ${comment.likes}`}>
        <StarIcon aria-hidden="true" />
        <span>{comment.likes}</span>
      </button>
      <button type="button" className="ex-comment-action">
        <ReplyIcon aria-hidden="true" />
        <span>Reply</span>
      </button>
    </div>
  );
}

/**
 * Abstract inline-SVG attachment, invented in the style of photo.tsx: a
 * gradient sky plus layered ridgelines, scaled down to a comment-row thumb.
 */
function CommentPhoto({ label, sub }: { label: string; sub: string }) {
  return (
    <figure className="ex-comment-photo">
      <svg viewBox="0 0 320 180" role="img" aria-label={label}>
        <defs>
          <linearGradient id="ex-comment-photo-sky" x1="0" y1="0" x2="0" y2="1">
            <stop offset="0" stopColor="#3b3768" />
            <stop offset="0.55" stopColor="#a2648a" />
            <stop offset="1" stopColor="#f2b477" />
          </linearGradient>
        </defs>
        <rect width="320" height="180" fill="url(#ex-comment-photo-sky)" />
        <circle cx="230" cy="58" r="26" fill="#fbe4b8" opacity="0.9" />
        <path d="M0 120 Q90 84 180 116 T320 104 V180 H0 Z" fill="#4f4160" />
        <path d="M0 148 Q110 112 220 142 T320 132 V180 H0 Z" fill="#332a48" />
        <path d="M0 168 Q140 146 320 164 V180 H0 Z" fill="#1f1a30" />
      </svg>
      <figcaption>
        <span className="ex-row-title">{label}</span>
        <span className="ex-row-sub">{sub}</span>
      </figcaption>
    </figure>
  );
}

function CommentRow({ comment, isReply = false }: { comment: Comment; isReply?: boolean }) {
  return (
    <div className={isReply ? "ex-comment ex-comment-reply" : "ex-comment"}>
      <span className="ex-avatar ex-comment-avatar">{initials(comment.name)}</span>
      <div className="ex-comment-body">
        <div>
          <span className="ex-comment-name">{comment.name}</span>
          <span className="ex-comment-meta">{comment.time}</span>
        </div>
        <div className="ex-comment-text">{comment.text}</div>
        {comment.photo ? (
          <CommentPhoto label={comment.photo.label} sub={comment.photo.sub} />
        ) : null}
        <CommentActions comment={comment} />
        {comment.replies?.map((reply, i) => (
          <CommentRow comment={reply} isReply key={`${reply.name}-${reply.time}-${i}`} />
        ))}
      </div>
    </div>
  );
}

const TOTAL_COUNT = COMMENTS.reduce((sum, c) => sum + 1 + (c.replies?.length ?? 0), 0);

/**
 * scrollbar="overlay" (Root) drives the panel's own auto-hiding thumb once
 * content is taller than the panel, at 'full' here. The panel's internal
 * scroll and the sheet's own reveal scroll are two separate things, and this
 * is the recipe for the former.
 */
export default function CommentsExample() {
  return (
    <Sheet.Root detents={["full"]} scrollbar="overlay">
      <Sheet.Trigger className="ex-trigger">View comments</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Comments">
        <Sheet.Handle />
        <div className="ex-panel-body">
          <div className="ex-comment-toolbar">
            <Sheet.Title>{TOTAL_COUNT} comments</Sheet.Title>
            <button type="button" className="ex-comment-sort">
              <TrendingUpIcon aria-hidden="true" />
              Top
            </button>
          </div>
          <div className="ex-comment-list">
            {COMMENTS.map((comment, i) => (
              <CommentRow comment={comment} key={`${comment.name}-${comment.time}-${i}`} />
            ))}
          </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 {
  --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-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-bg-elevated);
  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-row-title {
  font-weight: 600;
}

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

.ex-avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--ex-accent-soft);
  color: var(--ex-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.85rem;
  flex: none;
}

@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);
  }
}

/* ── Comments ────────────────────────────────────────────────────────────── */

/* No nested scroller here: the sheet's panel is the scroll container (the
   overlay scrollbar rides it), so the list just flows — a max-height +
   overflow of its own would trap 26 of the 30 comments inside a 340px
   inner box and leave the rest of the panel blank. */
.ex-comment-list {
  min-width: 0;
}

.ex-comment {
  display: flex;
  gap: 10px;
  padding: 12px 0;
  border-bottom: 1px solid var(--ex-border);
}

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

.ex-comment-avatar {
  width: 30px;
  height: 30px;
  font-size: 0.76rem;
}

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

.ex-comment-name {
  font-weight: 600;
  font-size: 0.86rem;
}

.ex-comment-meta {
  color: var(--ex-fg-faint);
  font-weight: 400;
  font-size: 0.76rem;
  margin-left: 6px;
}

.ex-comment-text {
  color: var(--ex-fg-muted);
  font-size: 0.88rem;
  line-height: 1.5;
  margin-top: 2px;
}

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

/* ── rich: comments ─────────────────────────────────────────────────────── */

.ex-comment-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 6px 0 14px;
}

.ex-comment-toolbar h2 {
  margin: 0;
}

.ex-comment-sort {
  display: flex;
  align-items: center;
  gap: 6px;
  flex: none;
  appearance: none;
  border: 1px solid var(--ex-border-strong);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg-muted);
  border-radius: var(--ex-radius-pill);
  padding: 6px 12px 6px 10px;
  font-size: 0.8rem;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
}

.ex-comment-sort:hover {
  border-color: var(--ex-accent);
  color: var(--ex-fg);
}

.ex-comment-sort svg {
  font-size: 0.95rem;
}

.ex-comment-reply {
  margin-left: 40px;
  margin-top: 12px;
  padding-top: 0;
  border-bottom: none;
}

.ex-comment-reply .ex-comment-avatar {
  width: 24px;
  height: 24px;
  font-size: 0.68rem;
}

.ex-comment-reply .ex-comment-name {
  font-size: 0.82rem;
}

.ex-comment-reply .ex-comment-text {
  font-size: 0.84rem;
}

.ex-comment-actions {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-top: 6px;
  margin-left: -6px;
}

.ex-comment-action {
  display: flex;
  align-items: center;
  gap: 5px;
  appearance: none;
  border: none;
  background: transparent;
  color: var(--ex-fg-faint);
  font-family: inherit;
  font-size: 0.78rem;
  font-weight: 600;
  padding: 6px;
  border-radius: var(--ex-radius-sm);
  cursor: pointer;
}

.ex-comment-action:hover {
  color: var(--ex-fg);
  background: var(--ex-bg-inset);
}

.ex-comment-action svg {
  font-size: 0.95rem;
}

.ex-comment-photo {
  margin: 8px 0 0;
  border-radius: var(--ex-radius-md);
  overflow: hidden;
  border: 1px solid var(--ex-border);
}

.ex-comment-photo svg {
  display: block;
  width: 100%;
  height: auto;
}

.ex-comment-photo figcaption {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: 8px 10px;
  background: 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

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 ReplyIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><g ><path d="M20 18v-2a4 4 0 0 0-4-4H4"/><path d="m9 17l-5-5l5-5"/></g></Icon>;
}

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>;
}

export function TrendingUpIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><g ><path d="M16 7h6v6"/><path d="m22 7l-8.5 8.5l-5-5L2 17"/></g></Icon>;
}

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>
      <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 {
  --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-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-bg-elevated);
  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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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}
      >
        <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 {
  --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-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-bg-elevated);
  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;
  height: 320px;
  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);
  }
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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>;
}

Live and non-modal

Ride tracker

The kitchen sink: sequential detents, snapTo, undimmed low detent, onTravel, onRelease.

ride-tracker.tsx

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

import { MapPinIcon, StarIcon } from "./icons";

const STOPS: DetentSpec[] = [0.25, 0.55, "full"];

/**
 * The kitchen-sink real-world sheet: a ride tracker exercising most of the
 * Root API at once. Controlled activeDetent + actionsRef.snapTo drive the
 * same travel; sequentialDetents keeps flings one stop at a time;
 * largestUndimmedDetent leaves the low docked state undimmed so the map
 * stays readable; onTravel feeds the ETA header's opacity; onRelease and
 * onOpenChangeComplete report the gesture lifecycle.
 */
export default function RideTrackerExample() {
  const actions = React.useRef<SheetActions>(null);
  const [active, setActive] = React.useState<DetentSpec>(0.25);
  const [status, setStatus] = React.useState("idle");
  const headerRef = React.useRef<HTMLDivElement>(null);

  return (
    <Sheet.Root
      detents={STOPS}
      activeDetent={active}
      onActiveDetentChange={setActive}
      sequentialDetents
      largestUndimmedDetent={0.25}
      closeThreshold={0.3}
      themeColorDimming
      actionsRef={actions}
      onOpenChangeComplete={(open) => setStatus(open ? "arrived" : "idle")}
      onRelease={(_event, willRemainOpen) => {
        setStatus(willRemainOpen ? "settled" : "dismissed");
      }}
      onTravel={(_revealedPx, progress) => {
        if (headerRef.current) {
          headerRef.current.style.opacity = String(Math.min(1, 0.4 + progress * 0.6));
        }
      }}
    >
      <div className="ex-ride-map-card">
        <svg
          className="ex-ride-map"
          viewBox="0 0 400 168"
          role="presentation"
          aria-hidden="true"
          focusable="false"
        >
          <rect className="ex-ride-map-bg" width="400" height="168" />
          <rect className="ex-ride-map-block" x="26" y="14" width="58" height="36" rx="7" />
          <rect className="ex-ride-map-block" x="146" y="112" width="72" height="38" rx="7" />
          <rect className="ex-ride-map-block" x="302" y="26" width="60" height="42" rx="7" />
          <path className="ex-ride-map-avenue" d="M0 96 C 90 66, 190 122, 400 82" />
          <path className="ex-ride-map-street" d="M74 0 C 58 58, 104 108, 90 168" />
          <path className="ex-ride-map-street" d="M254 0 C 232 52, 274 100, 246 168" />
          <path className="ex-ride-map-street" d="M0 34 C 96 12, 220 40, 400 6" />
          <path className="ex-ride-map-street" d="M0 152 C 128 140, 258 162, 400 140" />
          <path className="ex-ride-map-route-glow" d="M90 126 C 140 152, 208 40, 316 54" />
          <path className="ex-ride-map-route" d="M90 126 C 140 152, 208 40, 316 54" />
          <MapPinIcon
            className="ex-ride-map-pin ex-ride-map-pin-pickup"
            width={26}
            height={26}
            x={77}
            y={100}
          />
          <MapPinIcon
            className="ex-ride-map-pin ex-ride-map-pin-drop"
            width={24}
            height={24}
            x={304}
            y={30}
          />
        </svg>
        <Sheet.Trigger className="ex-trigger ex-ride-map-trigger">Track ride</Sheet.Trigger>
      </div>
      <Sheet.Content className="ex-panel" scrollbar="overlay" aria-label="Ride status">
        <Sheet.Handle />
        <div className="ex-panel-body">
          <div className="ex-ride-header" ref={headerRef}>
            <Sheet.Title>Arriving in 4 min</Sheet.Title>
            <Sheet.Description>Asha is 1.2 km away, silver hatchback</Sheet.Description>
          </div>
          <div className="ex-ride-driver">
            <span className="ex-avatar">AK</span>
            <div className="ex-ride-driver-body">
              <div className="ex-row-title">Asha Kulkarni</div>
              <div className="ex-ride-driver-rating">
                <StarIcon className="ex-ride-driver-star" /> 4.9
              </div>
            </div>
            <span className="ex-ride-plate">KA 05 AB 4521</span>
          </div>
          <div className="ex-ride-stops">
            {STOPS.map((stop) => (
              <button
                key={String(stop)}
                type="button"
                className="ex-btn"
                data-scrollsheet-no-drag
                aria-pressed={active === stop}
                onClick={() => actions.current?.snapTo(stop)}
              >
                {stop === "full" ? "Full" : `${Math.round(Number(stop) * 100)}%`}
              </button>
            ))}
          </div>
          <div className="ex-ride-route">
            {["Indiranagar pickup", "Koramangala drop"].map((place) => (
              <div className="ex-ride-stop" key={place}>
                <span className="ex-ride-pin" aria-hidden="true">
                  <MapPinIcon />
                </span>
                {place}
              </div>
            ))}
          </div>
          <p className="ex-note">
            release: <code>{status}</code> · detent: <code>{String(active)}</code>
          </p>
        </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 {
  --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-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: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-bg-elevated);
  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-note {
  color: var(--ex-fg-faint);
  font-size: 0.82rem;
  margin-top: 10px;
  text-align: center;
  max-width: 34ch;
}

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

.ex-avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--ex-accent-soft);
  color: var(--ex-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.85rem;
  flex: none;
}

@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);
  }
}

/* ── Ride tracker (kitchen sink) ────────────────────────────────────────── */

.ex-ride-header {
  transition: opacity 120ms linear;
}

.ex-ride-stops {
  display: flex;
  gap: 8px;
  margin: 4px 0 16px;
}

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

.ex-ride-route {
  display: grid;
  gap: 10px;
  margin-bottom: 16px;
}

.ex-ride-stop {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  background: var(--ex-bg-inset);
  border-radius: var(--ex-radius-md);
  font-weight: 550;
}

.ex-ride-pin {
  display: grid;
  place-items: center;
  color: var(--ex-accent);
}

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

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

.ex-ride-map-card {
  position: relative;
  width: 100%;
  max-width: 320px;
  border-radius: var(--ex-radius-lg);
  overflow: hidden;
  box-shadow: var(--ex-shadow-sm);
  margin-bottom: 4px;
}

.ex-ride-map {
  display: block;
  width: 100%;
  height: 168px;
}

.ex-ride-map-bg {
  fill: var(--ex-bg-inset);
}

.ex-ride-map-block {
  fill: var(--ex-border);
  opacity: 0.6;
}

.ex-ride-map-avenue {
  fill: none;
  stroke: var(--ex-fg-faint);
  stroke-width: 9;
  stroke-linecap: round;
  opacity: 0.5;
}

.ex-ride-map-street {
  fill: none;
  stroke: var(--ex-border-strong);
  stroke-width: 6;
  stroke-linecap: round;
}

.ex-ride-map-route-glow {
  fill: none;
  stroke: var(--ex-accent);
  stroke-width: 10;
  stroke-linecap: round;
  opacity: 0.16;
}

.ex-ride-map-route {
  fill: none;
  stroke: var(--ex-accent);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-dasharray: 1 9;
}

.ex-ride-map-pin {
  filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.25));
}

.ex-ride-map-pin-pickup {
  color: var(--ex-accent);
}

.ex-ride-map-pin-drop {
  color: var(--ex-fg-muted);
}

.ex-ride-map-trigger {
  position: absolute;
  left: 14px;
  bottom: 14px;
}

.ex-ride-driver {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 14px;
  margin-bottom: 14px;
  background: var(--ex-bg-inset);
  border-radius: var(--ex-radius-md);
}

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

.ex-ride-driver-rating {
  display: flex;
  align-items: center;
  gap: 4px;
  margin-top: 2px;
  color: var(--ex-fg-muted);
  font-size: 0.85rem;
}

.ex-ride-driver-star {
  width: 13px;
  height: 13px;
  flex: none;
}

.ex-ride-plate {
  flex: none;
  font-family: var(--ex-font-mono);
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  padding: 5px 9px;
  border: 1px solid var(--ex-border-strong);
  border-radius: var(--ex-radius-sm);
  background: var(--ex-bg-elevated);
  color: var(--ex-fg-muted);
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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 MapPinIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><g ><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></g></Icon>;
}

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>;
}

Live and non-modal

Toast

modal={false} with a queue in component state: toasts show one after another.

toast.tsx

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

import { CheckIcon } from "./icons";

interface ToastMessage {
  title: string;
  description: string;
}

const MESSAGES: ToastMessage[] = [
  { title: "Changes saved", description: "Your draft is up to date." },
  { title: "Link copied", description: "Anyone with the link can view." },
  { title: "Draft deleted", description: "Moved to trash for 30 days." },
];

/**
 * Toasts are consumer state, not a library concept: a queue in useState,
 * modal={false} so the page stays interactive, and one sheet that stays open
 * while the queue drains. Swapping the message re-measures the 'content'
 * detent, so height changes animate via the built-in morph.
 *
 * The card is an element INSIDE the panel rather than the panel itself: the
 * panel clips its own overflow, so the stacked cards peeking out behind the
 * front one need a shared positioned parent within it.
 */
export default function ToastExample() {
  const [queue, setQueue] = React.useState<ToastMessage[]>([]);
  const clicks = React.useRef(0);
  const current = queue[0];

  React.useEffect(() => {
    if (current === undefined) return;
    const timer = setTimeout(() => setQueue((q) => q.slice(1)), 3200);
    return () => clearTimeout(timer);
  }, [current]);

  // How many queued toasts sit behind the front one, capped at the two the
  // stack actually renders.
  const behind = Math.min(Math.max(queue.length - 1, 0), 2);

  return (
    <Sheet.Root
      modal={false}
      open={queue.length > 0}
      onOpenChange={(open) => {
        if (!open) setQueue([]);
      }}
      detents={["content"]}
    >
      <Sheet.Trigger
        className="ex-trigger"
        onClick={() => {
          const message = MESSAGES[clicks.current++ % MESSAGES.length]!;
          setQueue((q) => [...q, message]);
        }}
      >
        Show toast
      </Sheet.Trigger>
      <Sheet.Content className="ex-panel ex-toast-panel" aria-label="Notification">
        <div className="ex-toast-stack" data-behind={behind}>
          {behind > 1 && <div className="ex-toast-ghost ex-toast-ghost-2" aria-hidden="true" />}
          {behind > 0 && <div className="ex-toast-ghost ex-toast-ghost-1" aria-hidden="true" />}
          <div className="ex-toast-card" role="status">
            <span className="ex-toast-icon" aria-hidden="true">
              <CheckIcon />
            </span>
            <div className="ex-toast-copy">
              <div className="ex-toast-title">{current?.title}</div>
              <div className="ex-toast-desc">{current?.description}</div>
            </div>
            <button
              type="button"
              className="ex-toast-action"
              onClick={() => setQueue((q) => q.slice(1))}
            >
              Undo
            </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 {
  --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-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-bg-elevated);
  color: var(--ex-fg);
}

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

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

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

/* ── Toast ───────────────────────────────────────────────────────────────── */

.ex-toast-panel {
  --scrollsheet-inset-x: 16px;
  --scrollsheet-inset-bottom: 16px;
  /* The panel is only a positioning shell here: the visible card is
  .ex-toast-card inside it, so the stacked ghosts have a parent to sit
  behind within the panel's own overflow clip. Top room is reserved for
  the ghosts to peek out of. */
  margin: 0 auto;
  max-width: 380px;
  padding: 14px 0 0;
  background: transparent;
  box-shadow: none;
  border-radius: 0;
}

.ex-toast-stack {
  position: relative;
}

/* Sonner's collapsed stack: each queued toast behind the front one sits
   slightly higher and slightly narrower, so depth reads at a glance without
   showing their content. */
.ex-toast-ghost {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 100%;
  border: 1px solid var(--ex-border);
  border-radius: 8px;
  background: var(--ex-bg-elevated);
  box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
}

.ex-toast-ghost-1 {
  transform: translateY(-7px) scale(0.955);
}

.ex-toast-ghost-2 {
  transform: translateY(-14px) scale(0.91);
  opacity: 0.85;
}

/* Sonner's card: 8px radius, 1px border, 16px padding, 13px text, and a
   soft 4/12 shadow rather than a heavy elevation. */
.ex-toast-card {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 16px;
  border: 1px solid var(--ex-border);
  border-radius: 8px;
  background: var(--ex-bg-elevated);
  box-shadow: 0 4px 12px rgb(0 0 0 / 0.1);
  font-size: 13px;
}

.ex-toast-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  margin-top: 1px;
  border-radius: 50%;
  background: var(--ex-success);
  color: #fff;
}

.ex-toast-icon svg {
  width: 12px;
  height: 12px;
}

.ex-toast-copy {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.ex-toast-title {
  font-size: 13px;
  font-weight: 500;
  line-height: 1.35;
  color: var(--ex-fg);
}

.ex-toast-desc {
  font-size: 13px;
  line-height: 1.4;
  color: #3f3f3f;
}

.ex-toast-action {
  flex: none;
  height: 24px;
  padding: 0 8px;
  border: none;
  border-radius: 4px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  color: var(--ex-bg-elevated);
  background: var(--ex-fg);
}

.ex-toast-action:hover {
  opacity: 0.9;
}

@media (prefers-color-scheme: dark) {
  .ex-toast-desc {
    color: hsl(0deg 0% 91%);
  }
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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>;
}

Live and non-modal

Alert banner

side="top": anchored to the top edge, a system-style maintenance notice.

alert-banner.tsx

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

import { TriangleAlertIcon } from "./icons";

/**
 * side="top": the same primitives as a bottom sheet, anchored to the top
 * edge instead. A system-style banner: no handle, dismissed by its own
 * button, swipe up, or Esc.
 */
export default function AlertBannerExample() {
  const [open, setOpen] = React.useState(false);

  return (
    <Sheet.Root side="top" modal={false} open={open} onOpenChange={setOpen} detents={["content"]}>
      <Sheet.Trigger className="ex-trigger">Show alert</Sheet.Trigger>
      <Sheet.Content className="ex-panel ex-alert-panel" aria-label="Service alert">
        <div className="ex-alert-body">
          <span className="ex-alert-icon" aria-hidden="true">
            <TriangleAlertIcon />
          </span>
          <div className="ex-row-body">
            <div className="ex-row-title">Scheduled maintenance tonight</div>
            <div className="ex-row-sub">
              10&ndash;11pm IST. Checkout will be briefly unavailable.
            </div>
          </div>
          <Sheet.Close className="ex-btn" data-scrollsheet-no-drag>
            Dismiss
          </Sheet.Close>
        </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 {
  --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-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: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-bg-elevated);
  color: var(--ex-fg);
}

.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;
  }
}

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

/* ── Alert banner ───────────────────────────────────────────────────────── */

.ex-alert-panel {
  background: var(--ex-bg-elevated);
  color: var(--ex-fg);
  border-bottom: 1px solid var(--ex-border);
}

.ex-alert-body {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 16px 20px;
}

.ex-alert-icon {
  flex: none;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--ex-danger-soft);
  color: var(--ex-danger);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.9rem;
}

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

.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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

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 TriangleAlertIcon(props: React.ComponentProps<"svg">) {
  return <Icon {...props}><path d="m21.73 18l-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3M12 9v4m0 4h.01"/></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}>
      <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 {
  --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-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-bg-elevated);
  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);
  }
}

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

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

Mechanics

No handle

No <Sheet.Handle> rendered, the whole panel still drags and dismisses.

no-handle.tsx

import { Sheet } from "scrollsheet";

/**
 * The handle is optional. It's purely a click/keyboard affordance layered on
 * top of the drag engine, which listens on the whole panel, not the handle,
 * so omitting <Sheet.Handle> still leaves the whole panel draggable and
 * dismissible.
 */
export default function NoHandleExample() {
  return (
    <Sheet.Root>
      <Sheet.Trigger className="ex-trigger">Open sheet</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Drag anywhere">
        <div className="ex-panel-pad">
          <Sheet.Title>Drag anywhere</Sheet.Title>
          <Sheet.Description>
            There is no handle pill rendered here, but the whole panel still drags to dismiss or
            move between detents. Buttons, links, and inputs opt out of the drag on their own.
          </Sheet.Description>
          <Sheet.Close className="ex-btn ex-btn-block" data-scrollsheet-no-drag>
            Close
          </Sheet.Close>
        </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 {
  --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-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-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;
}

@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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

Mechanics

Custom backdrop

--scrollsheet-backdrop is one CSS variable, any color works.

custom-backdrop.tsx

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

/**
 * --scrollsheet-backdrop is one CSS variable, any color works: solid,
 * translucent, or a gradient behind a second custom property.
 */
export default function CustomBackdropExample() {
  return (
    <Sheet.Root>
      <Sheet.Trigger className="ex-trigger">Open sheet</Sheet.Trigger>
      <Sheet.Content
        className="ex-panel"
        aria-label="Custom backdrop"
        style={{ "--scrollsheet-backdrop": "rgb(90 69 232 / 0.55)" } as React.CSSProperties}
      >
        <Sheet.Handle />
        <div className="ex-panel-body">
          <Sheet.Title>Tinted backdrop</Sheet.Title>
          <Sheet.Description>
            <code>--scrollsheet-backdrop</code> is a single CSS variable that accepts any color. No
            overlay prop, no separate component to restyle.
          </Sheet.Description>
          <Sheet.Close className="ex-btn ex-btn-block" data-scrollsheet-no-drag>
            Close
          </Sheet.Close>
        </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 {
  --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-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-bg-elevated);
  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);
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

Mechanics

Full screen

detents={["full"]}: corners square off near the top of travel, then round back.

full-screen.tsx

import { Sheet } from "scrollsheet";

/**
 * detents={["full"]}: the tallest (only) detent reaches the true viewport
 * edge, so the built-in radius-flatten kicks in near the top of travel, the
 * panel squares off as it meets the screen edge, UISheetPresentationController
 * style, then rounds back on the way down. An article reader is a more
 * realistic backdrop for that transition than a blank panel, but the drag
 * mechanics are untouched.
 */
export default function FullScreenExample() {
  return (
    <Sheet.Root detents={["full"]}>
      <Sheet.Trigger className="ex-trigger">Open full screen</Sheet.Trigger>
      <Sheet.Content className="ex-panel" aria-label="Article">
        <Sheet.Handle />
        <div className="ex-article-cover">
          <svg
            viewBox="0 0 800 220"
            preserveAspectRatio="xMidYMid slice"
            role="img"
            aria-label="A jagged mountain ridge under a rising moon at dusk"
          >
            <defs>
              <linearGradient id="ex-article-sky" x1="0" y1="0" x2="0" y2="1">
                <stop offset="0" stopColor="#141935" />
                <stop offset="0.55" stopColor="#453a68" />
                <stop offset="1" stopColor="#dd9159" />
              </linearGradient>
            </defs>
            <rect width="800" height="220" fill="url(#ex-article-sky)" />
            <circle cx="640" cy="58" r="22" fill="#f6e7c8" opacity="0.9" />
            <circle cx="90" cy="40" r="1.4" fill="#f6e7c8" opacity="0.7" />
            <circle cx="150" cy="66" r="1" fill="#f6e7c8" opacity="0.5" />
            <circle cx="230" cy="34" r="1.2" fill="#f6e7c8" opacity="0.6" />
            <circle cx="320" cy="52" r="1" fill="#f6e7c8" opacity="0.5" />
            <circle cx="480" cy="30" r="1.4" fill="#f6e7c8" opacity="0.7" />
            <path
              d="M0 150 L60 122 L130 142 L210 100 L290 136 L370 108 L450 140 L540 104 L620 136 L700 112 L800 138 V220 H0 Z"
              fill="#4a4570"
            />
            <path
              d="M0 178 L90 148 L170 170 L250 130 L330 166 L410 138 L500 172 L590 142 L670 168 L750 146 L800 168 V220 H0 Z"
              fill="#332f52"
            />
            <path
              d="M0 206 L70 172 L150 198 L230 158 L310 194 L400 168 L480 202 L560 164 L650 198 L730 176 L800 200 V220 H0 Z"
              fill="#1c1a2e"
            />
          </svg>
        </div>
        <div className="ex-panel-body">
          <Sheet.Title>Learning to read a ridgeline</Sheet.Title>
          <div className="ex-article-byline">
            <span className="ex-avatar">NK</span>
            <div>
              <div className="ex-row-title">Nora Kessler</div>
              <div className="ex-row-sub">2 min read</div>
            </div>
          </div>
          <p>
            The first time I read a topo map correctly, I was already three switchbacks past where I
            meant to turn. Contour lines that had looked like scribbles for two years suddenly
            resolved into a shape: a saddle between two peaks, tight and unmistakable.
          </p>
          <p>
            Elevation gain numbers lie by omission. A trail listed at 2,000 feet over six miles can
            hide a single mile that does half the work, then flatten out and coast for the rest. The
            map shows this if you know where to look. Most trip reports don't.
          </p>
          <blockquote className="ex-pull-quote">
            A ridge doesn't care how fast you wanted to go.
          </blockquote>
          <p>
            Now I plan differently. I trace the contour lines before I check the mileage, because
            the lines tell me where the trail will actually ask something of me. The mileage just
            tells me how long I'll be out there.
          </p>
        </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 {
  --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-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-bg-elevated);
  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-row-title {
  font-weight: 600;
}

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

.ex-avatar {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: var(--ex-accent-soft);
  color: var(--ex-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: 700;
  font-size: 0.85rem;
  flex: none;
}

@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-photo-caption .ex-row-sub {
  color: rgb(255 255 255 / 0.75);
}

/* ── rich: full-screen ─────────────────────────────────────────────────────── */

.ex-article-cover {
  aspect-ratio: 800 / 220;
  overflow: hidden;
}

.ex-article-cover svg {
  display: block;
  width: 100%;
  height: 100%;
}

.ex-article-byline {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 14px 0 18px;
}

/* Classic editorial pull quote: hairlines above and below, not a side bar. */
.ex-pull-quote {
  margin: 4px 0 20px;
  padding: 12px 2px;
  border-top: 1px solid var(--ex-border-strong);
  border-bottom: 1px solid var(--ex-border-strong);
  font-size: 1.05rem;
  font-weight: 600;
  font-style: italic;
  color: var(--ex-fg);
  line-height: 1.5;
}

/* 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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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

Mechanics

Nested sheets

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]}>
      <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>
              <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 {
  --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-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-bg-elevated);
  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). */
::view-transition-group(ex-lb-shot) {
  animation-duration: 280ms;
  animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}

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>;
}