Nº 004 of 520
Bottom sheet with three detents
A draggable places sheet over a CSS-drawn map that snaps to 120px, 430px and full-height detents with spring easing, a progress-linked scrim and velocity flicks.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "Bottom sheet with three detents" (piece ios-bottom-sheet-detents) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 004 · "Bottom sheet with three detents" · designlounge.vercel.app -->
# Bottom sheet with three detents
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
The "Nearby" sheet from "Halden", a local-places app, sitting over a map. The sheet is a full-height panel translated down so that only a chosen amount is visible; it rests at three detents: **peek** (120px visible), **half** (430px) and **full** (782px, i.e. top edge 62px from the top of the phone). You drag it by its header with pointer events; on release it snaps to the nearest detent, or flicks one detent in the direction of a fast release. A scrim darkens the map only between half and full, in proportion to progress. Map pins and list rows are linked: tapping a pin selects the row and brings the sheet to half. The map is entirely CSS (gradients for roads, rounded blocks for parks and a lake), so there are no image dependencies.
## Reference behaviour
1. Initial state: sheet at **peek** (120px). Grabber, "Nearby · 18 places · 400 m" heading and the filter chips are visible; the list is below the fold. First pin ("Brød & Bønner") is pressed and its row is highlighted in `--accent`. Scrim opacity is 0.
2. Drag the header up: the sheet follows the pointer 1:1 with no transition. Past **full** the movement is damped to 25% (rubber band); the same below **peek**.
3. Release slowly: the sheet snaps to the nearest detent over 480ms `cubic-bezier(.32,.72,0,1)`.
4. Release fast (|velocity| > 0.4 px/ms): the sheet moves one detent in the flick direction regardless of where it is, clamped to the ends.
5. Between half and full the scrim's opacity rises linearly from 0 to 0.38 (`--scrim` = `#17191c`). Below half it is exactly 0 and `pointer-events:none`.
6. At full, tapping the scrim returns the sheet to half.
7. With the header focused, ArrowUp moves one detent up and ArrowDown one detent down.
8. Tap a map pin: the pin scales to 1.18, its row gets the accent name colour, the sheet snaps to half and the row scrolls into view. Tap a row: it becomes selected and the matching pin (if any) presses.
9. Chips are single-select (`aria-pressed`); the pressed chip fills with `--ink`.
10. The list scrolls independently inside the sheet once the sheet is at half or full; the header remains the only drag surface.
## Structure
```
390 × 844
┌────────────────────────────────────────┐
│ (54 status) │
│ 62 ┌ glass search pill 48 ───────────┐ │
│ │ ⌕ Near Grünerløkka, Oslo │ │
│ └─────────────────────────────────┘ │
│ ┌──┐ park ┌──┐ ▼ pin │ map: roads = white gradients,
│ │ │ ▼ pin │ │ │ parks #D3E3C8, lake #C9DCE6
│ └──┘ ─────── road ─┴──┴────────── │
│ ▼ pin park │
│ lake │
│ ───────────────────────────────────────│ ← full: top at 62
│ ▓▓▓▓▓▓▓▓▓▓ scrim 0→.38 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ┌───────────── ▬ grabber ─────────────┐│ ← half: 430 visible
… 172 more lines. Copy or download for the full brief. Loading the source…Brief
212 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
The “Nearby” sheet from “Halden”, a local-places app, sitting over a map. The sheet is a full-height panel translated down so that only a chosen amount is visible; it rests at three detents: peek (120px visible), half (430px) and full (782px, i.e. top edge 62px from the top of the phone). You drag it by its header with pointer events; on release it snaps to the nearest detent, or flicks one detent in the direction of a fast release. A scrim darkens the map only between half and full, in proportion to progress. Map pins and list rows are linked: tapping a pin selects the row and brings the sheet to half. The map is entirely CSS (gradients for roads, rounded blocks for parks and a lake), so there are no image dependencies.
Reference behaviour
- Initial state: sheet at peek (120px). Grabber, “Nearby · 18 places · 400 m” heading and the filter chips are visible; the list is below the fold. First pin (“Brød & Bønner”) is pressed and its row is highlighted in
--accent. Scrim opacity is 0. - Drag the header up: the sheet follows the pointer 1:1 with no transition. Past full the movement is damped to 25% (rubber band); the same below peek.
- Release slowly: the sheet snaps to the nearest detent over 480ms
cubic-bezier(.32,.72,0,1). - Release fast (|velocity| > 0.4 px/ms): the sheet moves one detent in the flick direction regardless of where it is, clamped to the ends.
- Between half and full the scrim’s opacity rises linearly from 0 to 0.38 (
--scrim=#17191c). Below half it is exactly 0 andpointer-events:none. - At full, tapping the scrim returns the sheet to half.
- With the header focused, ArrowUp moves one detent up and ArrowDown one detent down.
- Tap a map pin: the pin scales to 1.18, its row gets the accent name colour, the sheet snaps to half and the row scrolls into view. Tap a row: it becomes selected and the matching pin (if any) presses.
- Chips are single-select (
aria-pressed); the pressed chip fills with--ink. - The list scrolls independently inside the sheet once the sheet is at half or full; the header remains the only drag surface.
Structure
390 × 844
┌────────────────────────────────────────┐
│ (54 status) │
│ 62 ┌ glass search pill 48 ───────────┐ │
│ │ ⌕ Near Grünerløkka, Oslo │ │
│ └─────────────────────────────────┘ │
│ ┌──┐ park ┌──┐ ▼ pin │ map: roads = white gradients,
│ │ │ ▼ pin │ │ │ parks #D3E3C8, lake #C9DCE6
│ └──┘ ─────── road ─┴──┴────────── │
│ ▼ pin park │
│ lake │
│ ───────────────────────────────────────│ ← full: top at 62
│ ▓▓▓▓▓▓▓▓▓▓ scrim 0→.38 ▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │
│ ┌───────────── ▬ grabber ─────────────┐│ ← half: 430 visible
│ │ Nearby 18 places · 400m││
│ │ [All] [Coffee] [Bakeries] [Open now]││ ← peek: 120 visible
│ │──────────────────────────────────── ││
│ │ ◼ Brød & Bønner 4.8 · Bakery 120 m││
│ │ ◼ Vollen Park Kiosk 4.3 · ... 160 m││
│ │ ... ││
│ └─────────────────────────────────────┘│
└────────────────────────────────────────┘
.map(aria-hidden): absolute, full bleed; background is a stack oflinear-gradientroads over a 64pxrepeating-linear-gradientgrid on--map. Children: two.parkrounded blocks, one.waterblob (420px,border-radius:48% 52% 40% 60%, rotated −14°), three.blkbuilding blocks, three.lblstreet labels.- Three
<button class="pin" aria-pressed data-i>: 44×44 hit area, 30×38 SVG teardrop, anchored bymargin:-44px 0 0 -22pxsoleft/topis the pin tip. .top: glass search pill,top:62px, 48px tall..scrim: absolute full bleed,opacitydriven by JS.<section class="sheet" role="dialog" aria-label="Places nearby" aria-modal="false">: absolute,height:100%,transform: translateY(H − visible). Flex column:.head(drag handle,tabindex=0 role=button): grabber 36×5,<h2>,.chips.<ul class="list">:overflow-y:auto; flex:1, rows are<li tabindex=0>.
Tokens
:root {
/* map */
--map: #e9ece4; --road: #ffffff; --road-2: #f5f6f1;
--water: #c9dce6; --park: #d3e3c8; --block: #e0e3da;
/* sheet + text */
--sheet: #ffffff;
--ink: #17191c; --ink-2: #5f646b; --ink-3: #9198a1;
--line: #e6e8eb; --grab: #d3d6da;
--accent: #e2553b; /* pins, selected row, focus rings */
--accent-soft: #fbe7e2;
--star: #e0a12b; /* rating number */
--open: #2d8b7f; /* "Open" label */
--scrim: #17191c; /* at 0 → .38 opacity */
/* category icon squares */
--cat-green: #5b8a4c; --cat-orange: #c56a2c; --cat-blue: #3b6f9e;
--cat-plum: #8a5b9c; --cat-red: #b8433a; --cat-teal: #2d8b7f;
/* type */
--font: "Public Sans", system-ui, -apple-system, sans-serif;
/* detents (visible height) */
--peek: 120px; --half: 430px; --full: 782px; /* full = 844 − 62 */
--rubber: .25; /* overshoot damping */
--flick: .4; /* px/ms threshold */
/* layout */
--r-sheet: 22px; --r-icon: 12px; --r-chip: 16px;
--row-pad: 14px; --icon: 44px; --grabber: 36px 5px;
--sheet-shadow: 0 -1px 0 rgba(23,25,28,.06), 0 -12px 40px rgba(23,25,28,.14);
/* motion */
--t-micro: 160ms;
--t-snap: 480ms;
--spring: cubic-bezier(.32, .72, 0, 1);
--ease: cubic-bezier(.2, .7, .2, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Body / row | Public Sans | 15px | 400 | 1.4 | 0 | sentence |
| Sheet title | Public Sans | 22px | 700 | 1.2 | −0.02em | sentence |
| Title count | Public Sans | 13px | 500 | 1.2 | 0 | sentence |
| Chip | Public Sans | 13px | 500 | 1 | 0 | sentence |
| Row name | Public Sans | 15px | 600 | 1.3 | 0 | sentence |
| Row meta | Public Sans | 13px | 400 | 1.4 | 0 | sentence |
| Distance | Public Sans | 13px | 600 | 1.4 | 0 | tabular numerals |
| Search pill | Public Sans | 15px | 500/600 | 1.4 | 0 | sentence |
| Map label | Public Sans | 11px | 500 | 1.2 | +0.04em | UPPERCASE |
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Notes |
|---|---|---|---|---|---|---|
.sheet | pointer drag | transform | follows translateY(H − v) | 0 | none | class dragging removes the transition |
.sheet | release | transform | current → detent | 480ms | --spring | |
.sheet | overshoot | transform | damped: full + (v−full)·.25 | 0 | — | both ends |
.scrim | drag / snap | opacity | max(0,(v−half)/(full−half))·.38 | 0 / 480ms | --spring | pointer-events on only above half+10px |
.pin | pressed | transform | 1 → scale(1.18) | 160ms | --ease | |
.name | row selected | color | --ink → --accent | 160ms | --ease | |
| chip | pressed | bg/color | white/--ink-2 → --ink/white | 0 | — | instant |
Reduced motion: .sheet, .scrim and .pin transitions become 1ms. Snapping still happens; dragging still tracks the finger.
States
- Detent:
data-detent="peek|half|full"on the sheet, updated on every apply, so other UI can react. - Dragging:
.sheet.dragging,.scrim.dragging(no transitions); header cursorgrabbing. - Selected row:
.row.sel→ name in--accent; matching pinaria-pressed="true"and scaled. - Row focus-visible: 2px
--accentoutline,outline-offset:-2px, 8px radius. - Pin focus-visible: 2px
--accentoutline, 2px offset, circular. - Chip pressed:
--inkbackground, white text. Chip focus-visible: 2px accent outline, 2px offset. - Scrim at full: 0.38 opacity, clickable; tap returns to half.
- Empty (spec only): if a chip filters to nothing, the list shows “No places match” in
--ink-3centred with 40px padding; the sheet does not change detent.
Accessibility
- The sheet is
role="dialog" aria-modal="false"so the map stays reachable. The header isrole="button" tabindex="0"witharia-label="Drag to resize places sheet"; ArrowUp / ArrowDown step through detents. - Pins are
<button aria-label="Show <place>" aria-pressed>, 44×44 hit area (the visible teardrop is 30×38). - Rows are
<li tabindex="0">; Enter or Space selects. Focus order: search pill → pins → header → chips → rows. - Chips:
aria-pressed, single-select group. - Contrast:
--ink-2on white is 6.4:1;--star(#e0a12b) is used only for the bold 13px rating and sits beside text, not alone;--openon white is 4.6:1. - Announce detent changes with
aria-live="polite"text like “Sheet expanded” if your platform lacks native sheet semantics. - Never trap focus inside the sheet at peek or half.
Responsive rules
- Detents are in visible px, not percent, except
full = min(782, viewportHeight − 62). Recompute onresize. - 360 wide: rows and chips unchanged; the chips row hides overflow (no horizontal scroll indicator).
- ≥ 430 wide: sheet stays full-bleed; content inside is constrained to
max-width:480px; margin:0 auto. - Tablet: replace the bottom sheet with a 360px left panel at the same detent logic mapped to width (peek = 72px rail); the scrim is dropped.
- Landscape phone: half = 50% of height, full = height − 24px.
Acceptance checklist
- Sheet rests at exactly 120px, 430px and 782px visible (top edge at 62px when full) on a 390×844 viewport.
- Dragging the header moves the sheet 1:1 with no transition; overshoot beyond full or below peek is damped to 25%.
- Slow release snaps to the nearest detent in 480ms
cubic-bezier(.32,.72,0,1). - A release with |velocity| > 0.4 px/ms moves exactly one detent in the flick direction, clamped.
- Scrim opacity is 0 at and below half, 0.38 at full, linear in between, and only clickable above half.
- Tapping the scrim at full returns the sheet to half.
- ArrowUp / ArrowDown on the focused header step detents.
- Tapping a pin selects the matching row, scales the pin to 1.18 and snaps to half.
- The list scrolls inside the sheet; dragging the list does not move the sheet.
- The grabber is 36×5px,
#d3d6da, 3px radius, centred with 12px below it. - Sheet top corners are 22px radius with the two-layer shadow from the tokens.
- All pins, chips, rows and the header show a visible focus ring.
- Reduced motion: snaps are instantaneous, drag still tracks.
Implementation notes
Position by visible height, not by top. Keep the sheet height:100% and translate it by H − visible. That way the list’s scroll area is always the full remaining height and never needs a resize:
const det = () => [120, 430, Math.min(782, innerHeight - 62)];
function apply(v, animate) {
const [, half, full] = det();
sheet.classList.toggle('dragging', !animate);
sheet.style.transform = `translateY(${innerHeight - v}px)`;
scrim.style.opacity = (Math.max(0, (v - half) / (full - half)) * .38).toFixed(3);
scrim.style.pointerEvents = v > half + 10 ? 'auto' : 'none';
}
Rubber-band and flick on the header only. Capture the pointer so the drag survives leaving the element; track velocity from the last two move events:
head.addEventListener('pointerdown', e => { drag = true; startY = lastY = e.clientY; startVis = vis; head.setPointerCapture(e.pointerId); });
head.addEventListener('pointermove', e => {
if (!drag) return;
const [pk, , fu] = det(); let v = startVis + (startY - e.clientY);
if (v > fu) v = fu + (v - fu) * .25; if (v < pk) v = pk - (pk - v) * .25;
vel = (e.clientY - lastY) / Math.max(1, e.timeStamp - lastT); lastY = e.clientY; lastT = e.timeStamp;
apply(v, false);
});
On pointerup: if Math.abs(vel) > .4, snap to d[i ± 1] (negative velocity = finger moving up = expand); otherwise snap to the nearest detent to the released value.
Map without images. Roads are non-repeating linear-gradient(#fff 0 0) layers sized 100% 14px at a given y, over two repeating-linear-gradient grids at 64px pitch; parks are rounded divs. Keep touch-action:none on body so the browser does not steal the vertical drag.
Common mistakes: making the whole sheet the drag surface (then the list cannot scroll); animating top or height; forgetting setPointerCapture so fast drags drop the sheet; letting the scrim intercept taps at half; not removing the transition during drag (the sheet lags the finger by 480ms).
Details
- Palette
- Type
- Public Sans
- Motion
- Rich motion
- Build
- Multi-step
- Tags
On the shelves