Live and non-modal
Map search
Non-modal over a pannable map, a sticky search header that grows on focus.
maps.tsx
import * as React from "react";
import { Sheet, type DetentSpec } from "scrollsheet";
import { StarIcon } from "./icons";
interface Cafe {
name: string;
rating: string;
distance: string;
}
const CAFES: Cafe[] = [
{ name: "Ember & Oak", rating: "4.7", distance: "0.3 mi" },
{ name: "Third Wave Coffee Co.", rating: "4.5", distance: "0.4 mi" },
{ name: "The Roasting Room", rating: "4.8", distance: "0.6 mi" },
{ name: "Corner Pour", rating: "4.3", distance: "0.9 mi" },
{ name: "Slate Cafe", rating: "4.6", distance: "1.1 mi" },
];
const PEEK: DetentSpec = "124px";
const TALL: DetentSpec = 0.62;
/**
* The flex demo: non-modal so the map underneath stays pannable, a sticky
* search header (plain position: sticky, no library API for it), and a
* controlled activeDetent that grows on focus. Deliberately no blur-collapse:
* iOS fires transient blurs while the keyboard settles, so collapsing on
* blur bounces the sheet mid-keyboard — collapse stays a user gesture
* (handle or drag). Results morph in via the sheet's own content-morph.
*/
export default function MapsExample() {
const [open, setOpen] = React.useState(false);
const [active, setActive] = React.useState<DetentSpec>(PEEK);
const [query, setQuery] = React.useState("");
const showResults = active === TALL;
return (
<div className="ex-map-wrap">
<div className="ex-map" aria-hidden="true">
<span className="ex-map-street" style={{ top: "14%", left: "10%" }}>
5th Ave
</span>
<span className="ex-map-street" style={{ top: "58%", left: "62%" }}>
Ocean Blvd
</span>
<span className="ex-map-street" style={{ top: "82%", left: "20%" }}>
Elm St
</span>
<div className="ex-map-pin" />
</div>
{!open && (
<button
type="button"
className="ex-trigger"
style={{ position: "absolute", top: 14, left: 14 }}
onClick={() => setOpen(true)}
>
Open map search
</button>
)}
<Sheet.Root
modal={false}
open={open}
onOpenChange={setOpen}
detents={[PEEK, TALL]}
activeDetent={active}
onActiveDetentChange={setActive}
themeColorDimming
>
<Sheet.Content className="ex-panel ex-map-panel" aria-label="Search this area">
<Sheet.Handle />
<div className="ex-panel-body">
<div className="ex-map-search">
<input
type="search"
className="ex-input"
placeholder="Search coffee near you"
aria-label="Search this area"
value={query}
onChange={(event) => setQuery(event.target.value)}
onFocus={() => setActive(TALL)}
/>
</div>
{showResults && (
<div className="ex-map-results ex-map-results-scroll">
{CAFES.map((cafe) => (
<div className="ex-map-result" key={cafe.name}>
<div className="ex-map-result-name">{cafe.name}</div>
<div className="ex-map-result-meta">
<StarIcon className="ex-map-star" /> {cafe.rating} · {cafe.distance}
</div>
</div>
))}
</div>
)}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
);
}styles.css
Only the rules this example uses. Save it next to the component and it runs standalone.
/* ─────────────────────────────────────────────────────────────────────────
Shared example styling. Imported once by each app (playground/site) so
every example in this directory looks the same everywhere it runs, without
any example .tsx importing anything but "scrollsheet" and "react".
Self-contained token set (--ex-*), namespaced so it never collides with a
host app's own design tokens. scrollsheet itself ships a real default
look for the panel (background/radius/shadow, see src/internal/styles.ts).
Everything below styles the *content* inside it, plus a few panel
variants (inset card, toast) that intentionally override that default.
───────────────────────────────────────────────────────────────────────── */
:root {
/* The edge-attached panel surface. Deliberately equal to the host page's
own background so that a bottom sheet, the page behind it, and the
page's <meta name="theme-color"> are all one color: theme-color is a
single page-level value that browsers apply to every piece of chrome at
once, so the only way the bottom bar can match the sheet AND the top bar
match the page is for the sheet and the page to agree. Detached panels
(.ex-panel-inset) keep --ex-bg-elevated: they float above the page and
never touch the viewport edge, so they still need to read as raised. */
--ex-panel-bg: #ffffff;
--ex-bg-elevated: #ffffff;
--ex-bg-inset: #f5f5f4;
--ex-fg: #1c1917;
--ex-fg-muted: #57534e;
--ex-fg-faint: #78716c;
--ex-border: #e7e5e4;
--ex-border-strong: #d6d3d1;
--ex-accent: #5a45e8;
--ex-accent-fg: #ffffff;
--ex-accent-soft: #efecff;
--ex-danger: #c4392f;
--ex-danger-soft: #fbe9e6;
--ex-success: #15803d;
--ex-success-soft: #e8f5ec;
--ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
--ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
--ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
--ex-radius-sm: 8px;
--ex-radius-md: 14px;
--ex-radius-lg: 22px;
--ex-radius-xl: 32px;
--ex-radius-pill: 999px;
--ex-font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--ex-panel-bg: #0a0a0b;
--ex-bg-elevated: #1c1c1e;
--ex-bg-inset: #222225;
--ex-fg: #f4f4f5;
--ex-fg-muted: #a1a1aa;
--ex-fg-faint: #71717a;
--ex-border: #303033;
--ex-border-strong: #3f3f46;
--ex-accent: #9385ff;
--ex-accent-fg: #0a0a0b;
--ex-accent-soft: #23204a;
--ex-danger: #ff7a70;
--ex-danger-soft: #2c1917;
--ex-success: #4ade80;
--ex-success-soft: #12271a;
--ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
--ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
--ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}
}
/* An explicit theme choice must beat the media query in BOTH directions.
A host that offers a theme toggle sets data-theme on the root element,
and without these two blocks the examples keep following the OS instead:
a dark-OS visitor who picks light gets a light page around sheets that
stay dark. The tokens are restated rather than shared because a media
query and a plain selector cannot be combined into one rule. */
:root[data-theme="light"] {
--ex-panel-bg: #ffffff;
--ex-bg-elevated: #ffffff;
--ex-bg-inset: #f5f5f4;
--ex-fg: #1c1917;
--ex-fg-muted: #57534e;
--ex-fg-faint: #78716c;
--ex-border: #e7e5e4;
--ex-border-strong: #d6d3d1;
--ex-accent: #5a45e8;
--ex-accent-fg: #ffffff;
--ex-accent-soft: #efecff;
--ex-danger: #c4392f;
--ex-danger-soft: #fbe9e6;
--ex-success: #15803d;
--ex-success-soft: #e8f5ec;
--ex-shadow-sm: 0 1px 2px rgb(12 11 10 / 0.08);
--ex-shadow-md: 0 8px 24px -8px rgb(12 11 10 / 0.2);
--ex-shadow-lg: 0 24px 64px -20px rgb(12 11 10 / 0.32);
}
:root[data-theme="dark"] {
--ex-panel-bg: #0a0a0b;
--ex-bg-elevated: #1c1c1e;
--ex-bg-inset: #222225;
--ex-fg: #f4f4f5;
--ex-fg-muted: #a1a1aa;
--ex-fg-faint: #71717a;
--ex-border: #303033;
--ex-border-strong: #3f3f46;
--ex-accent: #9385ff;
--ex-accent-fg: #0a0a0b;
--ex-accent-soft: #23204a;
--ex-danger: #ff7a70;
--ex-danger-soft: #2c1917;
--ex-success: #4ade80;
--ex-success-soft: #12271a;
--ex-shadow-sm: 0 1px 2px rgb(0 0 0 / 0.4);
--ex-shadow-md: 0 8px 24px -8px rgb(0 0 0 / 0.5);
--ex-shadow-lg: 0 24px 64px -20px rgb(0 0 0 / 0.6);
}
/* ── Triggers & generic buttons ─────────────────────────────────────────── */
.ex-trigger {
appearance: none;
border: 1px solid var(--ex-border-strong);
background: var(--ex-bg-elevated);
color: var(--ex-fg);
border-radius: var(--ex-radius-sm);
padding: 11px 20px;
font-size: 0.92rem;
font-weight: 600;
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease;
}
.ex-trigger:hover {
border-color: var(--ex-accent);
}
.ex-trigger:active {
transform: scale(0.97);
}
/* ── Panel content wrappers ──────────────────────────────────────────────
Sheet.Content carries .ex-panel (or .ex-panel-inset); a body wrapper
inside supplies the padding, since the handle sits outside it. */
.ex-panel {
background: var(--ex-panel-bg);
color: var(--ex-fg);
}
.ex-panel-body {
padding: 4px 22px 28px;
}
.ex-panel-body h2,
.ex-panel-pad h2 {
font-size: 1.2rem;
margin: 6px 0 8px;
}
.ex-panel-body p,
.ex-panel-pad p {
color: var(--ex-fg-muted);
line-height: 1.55;
margin: 0 0 16px;
}
.ex-input,
.ex-textarea {
display: block;
width: 100%;
appearance: none;
border: 1px solid var(--ex-border-strong);
border-radius: var(--ex-radius-sm);
padding: 12px 14px;
font-size: 1rem;
background: var(--ex-bg-elevated);
color: var(--ex-fg);
font-family: inherit;
}
.ex-input:focus-visible,
.ex-textarea:focus-visible {
outline: 2px solid var(--ex-accent);
outline-offset: 1px;
}
@keyframes ex-wallet-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
/* ── Maps ────────────────────────────────────────────────────────────────── */
.ex-map-wrap {
position: relative;
width: 100%;
max-width: 380px;
/* Same height as .ex-ride-map-card: the two live/non-modal previews sit
next to each other and read as one pair only if their canvases match. */
height: 200px;
border-radius: var(--ex-radius-md);
overflow: hidden;
box-shadow: var(--ex-shadow-sm);
}
.ex-map {
position: absolute;
inset: 0;
background:
linear-gradient(135deg, rgb(90 69 232 / 0.14), transparent 60%),
repeating-linear-gradient(0deg, var(--ex-border) 0 1px, transparent 1px 64px),
repeating-linear-gradient(90deg, var(--ex-border) 0 1px, transparent 1px 64px),
var(--ex-bg-inset);
}
.ex-map-street {
position: absolute;
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ex-fg-faint);
text-transform: uppercase;
pointer-events: none;
}
.ex-map-pin {
position: absolute;
top: 46%;
left: 48%;
width: 16px;
height: 16px;
border-radius: 50% 50% 50% 0;
background: var(--ex-accent);
transform: rotate(-45deg);
box-shadow: 0 2px 6px rgb(0 0 0 / 0.3);
}
.ex-map-search {
/* The search field never scrolls: the results list below owns its own
overflow instead, which holds on every engine (sticky inside a
toggling-overflow panel is flaky on iOS Safari). */
background: var(--ex-bg-elevated);
/* Clear of the Handle's 44px hit box: with the input flush under the
pill, iPhone touch adjustment snaps taps aimed at the field's top half
onto the handle button — the sheet cycles detents and the keyboard
never opens. 16px puts the whole field outside the snap contest. */
margin-top: 16px;
padding-bottom: 12px;
margin-bottom: 4px;
}
/* Flex column down the panel so the results list is sized by what's left
after the search row — exactly the sheet's remaining height, keyboard or
not — instead of a hardcoded vh guess that either clips short or leaves
dead space when the keyboard caps the panel at the visual viewport. The
chain passes through the library's [data-scrollsheet-body] wrapper (a
documented hook); the library can't stretch it by default because the
'content' detent measures that wrapper's natural height. */
.ex-map-panel {
display: flex;
flex-direction: column;
}
.ex-map-panel [data-scrollsheet-body] {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.ex-map-panel .ex-panel-body {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.ex-map-results-scroll {
overflow-y: auto;
overscroll-behavior: auto;
flex: 1;
min-height: 0;
}
.ex-map-results {
padding-bottom: 8px;
}
.ex-map-result {
padding: 12px 0;
border-bottom: 1px solid var(--ex-border);
}
.ex-map-result:last-child {
border-bottom: none;
}
.ex-map-result-name {
font-weight: 600;
font-size: 0.95rem;
}
.ex-map-result-meta {
color: var(--ex-fg-muted);
font-size: 0.82rem;
margin-top: 2px;
}
@keyframes ex-spin {
to {
transform: rotate(360deg);
}
}
/* ── rich: ride ─────────────────────────────────────────────────────── */
/* Same grid-and-gradient backdrop as .ex-map (Map search): the two examples
sit in the same gallery group, and matching the canvas is most of what
makes this one read as a card instead of an illustration. */
.ex-ride-map-card {
position: relative;
width: 100%;
max-width: 380px;
height: 200px;
border-radius: var(--ex-radius-md);
overflow: hidden;
box-shadow: var(--ex-shadow-sm);
margin-bottom: 4px;
background:
linear-gradient(135deg, rgb(90 69 232 / 0.14), transparent 60%),
repeating-linear-gradient(0deg, var(--ex-border) 0 1px, transparent 1px 64px),
repeating-linear-gradient(90deg, var(--ex-border) 0 1px, transparent 1px 64px),
var(--ex-bg-inset);
}
/* Desktop: present as a centered lightbox card instead of the library's
default right-docked drawer. Two library levers, no new API:
--scrollsheet-inset-bottom (already read by measure() to flag the sheet
data-scrollsheet-detached, which centers it vertically for a single
detents= {
['full']
}
panel for free) plus overriding the dock's left/right/
width back to a max-width + auto-margin center, the same recipe core.css's
own top-side desktop rule uses. Only correct for a single ['full'] detent
with disableDrag, exactly what this example uses — a shorter detent stays
bottom-anchored inside the floating region instead of centering, since the
panel's near edge is fixed and only its far edge moves. */
@media (min-width: 768px) {
.ex-lb-panel {
--scrollsheet-inset-bottom: var(--scrollsheet-desktop-margin, 24px);
left: var(--scrollsheet-inset-x, 0px);
right: var(--scrollsheet-inset-x, 0px);
width: auto;
max-width: min(480px, calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px)));
margin-inline: auto;
}
/* The card hugs the photo instead of standing full-viewport-tall with
dead space under it. Doubled class outranks the injected core rule's
height (same specificity would lose on source order — core.css loads
after this file). It stays anchored to the floating region's bottom
edge — the panel's coordinate space is the canvas (viewport plus
detent runway), so viewport-centering tricks land in the wrong space.
Safe only because this sheet is single-['full']-detent with
disableDrag: nothing reads the panel height for snap math. */
.ex-panel.ex-lb-panel {
height: fit-content;
max-height: calc(100% - 2 * var(--scrollsheet-desktop-margin, 24px));
}
}
/* Thumbnail-to-viewer morph (View Transitions API, progressive). The
custom properties come from lightbox.tsx's MORPH_SPRING — a linear()
spring curve, the same shape the sheet's own open/close travel uses,
instead of a hand-picked cubic-bezier that would only ease at one end.
The fallback values are the pre-spring cubic-bezier, in case that
<style> tag is ever missing (JS disabled, or this rule copied out on
its own): a one-sided ease is still better than no easing at all. */
::view-transition-group(ex-lb-shot) {
animation-duration: var(--ex-lb-morph-dur, 280ms);
animation-timing-function: var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1));
}
/* Entrance: starts a beat after the photo (30% of its duration) and
finishes before it (60% long, ending at 90%) — chrome materializes
around a subject already mid-flight instead of racing it. Exit: no
delay, under half the duration — chrome should clear fast, not linger
while the photo shrinks back to its thumbnail. Same spring curve as the
photo morph on the way in for one consistent character; a plain ease-in
on the way out is fine since nothing is being watched leave. */
@keyframes ex-lb-bar-in-top {
from {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes ex-lb-bar-out-top {
to {
opacity: 0;
transform: translateY(-10px);
}
}
@keyframes ex-lb-bar-in-bottom {
from {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes ex-lb-bar-out-bottom {
to {
opacity: 0;
transform: translateY(10px);
}
}
::view-transition-new(ex-lb-topbar) {
animation: ex-lb-bar-in-top calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}
::view-transition-old(ex-lb-topbar) {
animation: ex-lb-bar-out-top calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}
::view-transition-new(ex-lb-bottombar) {
animation: ex-lb-bar-in-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.6)
var(--ex-lb-morph-ease, cubic-bezier(0.16, 1, 0.3, 1)) calc(var(--ex-lb-morph-dur, 280ms) * 0.3) both;
}
::view-transition-old(ex-lb-bottombar) {
animation: ex-lb-bar-out-bottom calc(var(--ex-lb-morph-dur, 280ms) * 0.4) ease-in both;
}
/* Mobile: triggers and buttons drop to a tighter, native-feeling size. */
@media (max-width: 719px) {
.ex-trigger,
.ex-btn {
padding: 8px 14px;
font-size: 0.88rem;
}
}icons.tsx
/**
* Inline lucide icons (ISC) used by the examples — kept dependency-free so
* the examples stay copy-pasteable without an icon package install.
* Generated file: edit the generator, not this by hand.
*/
import * as React from "react";
function Icon({ children, ...props }: React.ComponentProps<"svg">) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
{children}
</svg>
);
}
export function StarIcon(props: React.ComponentProps<"svg">) {
return (
<Icon {...props}>
<path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.12 2.12 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.12 2.12 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.12 2.12 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.12 2.12 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.12 2.12 0 0 0 1.597-1.16z" />
</Icon>
);
}