Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp, MapPin } from "lucide-react"
import { cn } from "@/lib/utils"

/**
 * How the panel presents itself. The physics, the keyboard map and the recentring
 * are identical in all three — only the frame and the header row change.
 */
export type MapBottomSheetVariant = "sheet" | "inset" | "pill"

/** What the sheet is covering right now: the numbers a map SDK needs for its padding. */
export interface MapBottomSheetViewport {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/map-bottom-sheet.json

Prompt

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.

On This Page