Map Bottom Sheet
A map with a draggable sheet over it: detents, velocity settling, and a map that lifts its focal point by half of whatever the sheet covers.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/map-bottom-sheet.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MapBottomSheet" component — a full-bleed
map with a sheet dragged up over it, where the map gets out of the sheet's way.
React + lucide-react only: no gesture library, no animation library, no map SDK
(the map is a slot the consumer fills).
Contract
- "use client". forwardRef<HTMLDivElement, MapBottomSheetProps> extending
Omit<React.HTMLAttributes<HTMLDivElement>, "title">; the rest props spread onto
the root, className merged with cn().
- Props:
- map: ReactNode — required. Rendered full-bleed behind the sheet and never
unmounted, so panning it costs nothing: an SDK canvas, an <img>, an SVG.
- detents = [0.2, 0.55, 0.92] — resting heights as fractions of the component's
own height (peek / half / almost-full). Sanitised, not trusted: drop
non-finite values, clamp each into [0.08, 1], de-duplicate, sort ascending,
fall back to the default when nothing survives. A 0 or negative stop would
squash the sheet into a sliver no thumb can find again, and this sheet is
never allowed to disappear.
- detent?: number, defaultDetent = 0, onDetentChange?: (index: number) => void
— controlled and uncontrolled both supported; the index is into the sorted
detents.
- onViewportChange?: (v: MapBottomSheetViewport) => void where
MapBottomSheetViewport = { detent, ratio, obstructedBottom, visibleHeight,
offsetY }. These are exactly the numbers a map SDK wants:
map.setPadding({ bottom: obstructedBottom }) or an easeTo with offsetY.
- variant = "sheet" | "inset" | "pill" (default "sheet"), recenter = "pan" |
"none" (default "pan"), title?, subtitle?, headerAccessory?, controls?,
footer?, collapseOnMapTap = true, showHandle = true, label?.
- There is no `open` prop and no close: the smallest detent is the floor. A sheet
that can be dismissed is a drawer, and a drawer does not owe the surface behind
it anything — this one does.
Behavior
- ONE NUMBER DRIVES THREE LAYERS. The live position is written to CSS custom
properties on the root — `--map-sheet-ratio` (screen-relative),
`--map-sheet-hidden` (panel-relative, = 1 - ratio / largest detent) and
`--map-sheet-settle` (the transition duration) — and the panel, the map layer
and the floating controls each derive their own transform from them. A drag
frame is therefore one style write instead of a React render (the list inside
is not re-rendered at 60fps) and the three layers can never disagree about
where the sheet is.
- RECENTRING. With recenter="pan" the built-in map layer is rendered 150% of the
component's height and parked at -16.667% of itself, which centres it while the
sheet is down; every unit of ratio moves it up a further 33.333% of itself,
i.e. half the screen. Half the obstruction is the offset that keeps the focal
point in the middle of the strip that is still visible. Being over-tall is what
guarantees no empty band can ever appear under the map. recenter="none" leaves
the layer alone for consumers whose SDK owns the camera; they drive it from
onViewportChange instead.
- GESTURE. Pointer events only (never parallel mouse/touch handlers), isolated by
pointerId — a started drag belongs to the first finger and a second finger or a
palm cannot take it over. setPointerCapture is called on the element that
started the drag and released on that same node, including the cancelled path.
A press becomes a drag after 3px of vertical movement, and only if the vertical
movement exceeds the horizontal; a clearly horizontal gesture (a carousel
inside the list) is handed back for good and never re-evaluated. A press
landing on a button / link / input / [data-sheet-no-drag] never starts a drag;
[data-sheet-drag-zone] always does.
- SCROLL-THEN-DRAG, FLIPPED BY DETENT. Below the largest detent the list is
touch-action: none, so every drag from it moves the sheet — at half height,
scrolling a list you can only half see is not what anyone means. At the largest
detent the list scrolls natively (touch-pan-y) and the sheet only claims a
downward drag that began with the nearest scrollable ancestor at scrollTop 0.
- RELEASE. Track a lightly smoothed px/ms velocity. Above 0.5 px/ms jump to the
next detent in the fling direction (a hard flick from peek goes straight to
full); below it settle on the nearest detent by distance. A pointercancel
settles back on the detent the gesture started from and changes nothing.
- OVER-DRAG. Hard clamp at the largest detent; below the smallest, rubber-band at
30% of the excess capped at 5% of the screen. The sheet gives under the finger,
it never leaves.
- REFUSAL. Every settle bumps a counter that is part of the settle effect's
dependencies, so the position is repainted even when a controlled parent kept
`detent` where it was. That is what makes a capped sheet spring home instead of
being stranded wherever the finger let go.
- TAP THE MAP TO COLLAPSE. The map is its own layer with its own pointer
handlers, so the sheet never sees those events. pointerdown records point and
timeStamp; pointerup collapses to the smallest detent only within 8px and
500ms; pointercancel drops the record. The record is read AND cleared in the
same handler, so one press can collapse at most once and a pan is never a tap.
collapseOnMapTap=false for maps that are themselves interactive.
- KEYBOARD, WHICH IS NOT OPTIONAL — a height only fingers can change is a flat
accessibility hole. The handle is a real role="slider" with
aria-valuemin/max/now and an aria-valuetext of "N% of the screen":
ArrowUp/PageUp and ArrowDown/PageDown step one detent, Home/End jump to the
ends. The header chevron expands/collapses in one press with aria-expanded and
aria-controls pointing at the body. Escape on the panel collapses to the
smallest detent, and only when there is something to collapse — handled on the
panel's own onKeyDown with stopPropagation so the innermost layer owns the key,
after calling the consumer's onKeyDown and bailing on defaultPrevented.
Tabbing to a row that is below the fold while collapsed raises the sheet one
detent, gated on :focus-visible so a tap never jerks the sheet out from under
the finger that just landed on it.
- DEGENERATE CASES. One detent = a fixed shelf: the arrow keys do nothing and the
chevron reports aria-disabled with a guard in its click handler — never the
native `disabled` attribute, because the browser blurs a node the instant it is
disabled and focus falls to <body>. An empty body still holds its shelf. A long
title clamps at two lines; a long subtitle truncates.
- REPORTING. onViewportChange fires on mount, on every settle and on resize
(ResizeObserver, rAF-coalesced — a rotation changes the pixel height without
changing the detent), and never per drag frame: a map SDK re-easing its padding
sixty times a second is how you drop frames. Keep the consumer's callback in a
ref so an inline arrow function does not give the reporting effect a new
identity on every render and loop a host that stores the viewport in state.
- MOTION. prefers-reduced-motion is subscribed through matchMedia
(useSyncExternalStore), not read once: the settle duration becomes 0ms and
detent changes teleport. Dragging, flinging, tapping, the keyboard and the
viewport reporting all keep working — only the easing is gone.
- CLEANUP. The drag rAF is cancelled on unmount, the ResizeObserver disconnected
along with its own rAF, the matchMedia listener removed, pointer capture
released on the node that took it.
- The sheet is chrome, not a dialog: no backdrop, no focus trap, no body scroll
lock, no unmount. The map behind it stays live and hit-testable at every
detent, which is the entire reason this is not a drawer.
Rendering & styling
- Semantic tokens only: root bg-muted (the map's own backdrop), panel bg-card /
text-card-foreground with border and shadow-2xl, handle bg-muted-foreground/40,
subtitle text-muted-foreground, focus rings focus-visible:ring-2 ring-ring.
Monochrome by construction — the highest-priority elements invert to
bg-foreground / text-background rather than taking a hue. No hex, no rgb(), no
oklch().
- Variants change the frame and the header row only; the physics, the keyboard
map and the recentring are identical in all three. "sheet" is full-bleed with
rounded-t-2xl; "inset" leaves a gutter on both sides and closes its border all
the way round; "pill" collapses the header into one rounded-full row — pin,
truncated title, trailing count, chevron — for a status strip that is still a
full sheet when pulled up.
- Safe area on every edge it touches: the panel pads left/right by
max(var(--safe-area-inset-left|right, env(safe-area-inset-*)), 0px), the footer
and the bottom of the scroll body by max(inset-bottom, 0.75rem), and the
floating controls stop 5.5rem + inset-top short of the top so a full-height
sheet never pushes them under the notch. Reading --safe-area-inset-* BEFORE
env() is deliberate: a device-frame preview can then simulate a notch on
hardware that reports none.
- Mobile type scale: title 15px/700 line-clamped to two lines, subtitle 12px
tabular-nums truncated, list rows 13px. Radius ladder 2xl / lg / rounded.
- Touch: the chevron is a 44px target, the whole header is the drag zone, and
nothing depends on hover.
- Accessibility: the panel is role="region" labelled by the title (or `label`
when there is none), the scroll body is the chevron's aria-controls target, the
map layer and every decorative glyph are aria-hidden.
Customization levers
- Detents are the feature: [0.16, 0.62] for a ride-status pill, [0.2, 0.55, 0.92]
for a search sheet, [0.3, 0.78] for a venue card, a single value for a fixed
shelf that cannot be dragged anywhere.
- variant picks the frame; drop title/subtitle entirely and build your own header
inside children marked data-sheet-drag-zone.
- Camera ownership: recenter="pan" for the built-in layer, recenter="none" plus
onViewportChange when Mapbox / MapLibre / Google Maps owns the camera. Change
the offset rule by editing the two derived percentages at the top of the file
(half the obstruction is a convention, not a law — a third reads calmer on
tall content).
- Gesture feel: the 3px arm threshold, the 0.5 px/ms fling threshold, the 320ms
settle, the cubic-bezier(0.32, 0.72, 0, 1) easing, the 30% rubber band and the
8px / 500ms tap slop are named constants at the top of the file.
- Chrome slots: controls (they ride the sheet's top edge as it moves), footer (a
pinned action bar, never scrolls, never drags), headerAccessory (an ETA chip,
an avatar, a close button).
- Height: the root is h-[560px] so it is usable inside docs and cards; give it
h-dvh in an app shell.
- Surface: swap the panel's bg-card for bg-background/80 + backdrop-blur to get a
glass sheet without leaving the token system.Concepts
- Detent — a resting height expressed as a fraction of the screen, not a pixel count. Three of them (peek / half / almost-full) turn a panel into a stateful surface a thumb can park anywhere, and they are what a fling has to land on. Sanitising them is not paranoia: one bad number and the sheet becomes a sliver nobody can grab back.
- Half-the-obstruction recentring — the rule that makes this a map component rather than a sheet that happens to sit on a map. Every pixel the sheet covers moves the focal point up half a pixel, which is exactly the offset that keeps it in the middle of the strip still visible. Without it, the thing you searched for spends the whole interaction hidden behind the panel you opened to read about it.
- One CSS variable, three layers — the live position is a custom property on the root; the panel, the map and the floating controls each derive their own transform from it. A drag frame becomes one style write instead of a re-render of the list, and the three can never drift apart the way three pieces of React state would.
- Scroll-then-drag priority, flipped by detent — below full height the list does not scroll at all and every drag moves the sheet; at full height the list scrolls and only a pull from its very top belongs to the sheet. The same finger movement means two different things depending on how much of the screen the sheet already owns.
- Tap versus pan on the map — collapsing on a map tap is only safe if a tap can be told from a pan: within 8px and 500ms, recorded and cleared inside the same handler so one press can fire at most once. Get this wrong and every attempt to drag the map slams the sheet shut.
- Chrome, not a dialog — no backdrop, no focus trap, no scroll lock, no close. The surface behind stays live at every detent, which is why the sheet also has to publish what it is covering (
obstructedBottom,offsetY) instead of assuming nobody is looking.
Pull Menu
An overscroll quick-action menu: pull a list past its top to reveal actions, the one under your thumb arms as you keep pulling or slide sideways, and releasing runs it.
Passcode Lock
An app lock screen with keypad passcode entry, biometric fallback, escalating attempt backoff and a terminal locked-out state.