Design Lounge
Install

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

Open full size
390 × 844 · 13.3 KB · 141 lines

Use it

Free to use · credit appreciated

1 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.

Brief

212 lines

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
│ │ 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 of linear-gradient roads over a 64px repeating-linear-gradient grid on --map. Children: two .park rounded blocks, one .water blob (420px, border-radius:48% 52% 40% 60%, rotated −14°), three .blk building blocks, three .lbl street labels.
  • Three <button class="pin" aria-pressed data-i>: 44×44 hit area, 30×38 SVG teardrop, anchored by margin:-44px 0 0 -22px so left/top is the pin tip.
  • .top: glass search pill, top:62px, 48px tall.
  • .scrim: absolute full bleed, opacity driven 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

RoleFamilySizeWeightLine-heightTrackingCase
Body / rowPublic Sans15px4001.40sentence
Sheet titlePublic Sans22px7001.2−0.02emsentence
Title countPublic Sans13px5001.20sentence
ChipPublic Sans13px50010sentence
Row namePublic Sans15px6001.30sentence
Row metaPublic Sans13px4001.40sentence
DistancePublic Sans13px6001.40tabular numerals
Search pillPublic Sans15px500/6001.40sentence
Map labelPublic Sans11px5001.2+0.04emUPPERCASE

Motion

ElementTriggerPropertyFrom → ToDurationEasingNotes
.sheetpointer dragtransformfollows translateY(H − v)0noneclass dragging removes the transition
.sheetreleasetransformcurrent → detent480ms--spring
.sheetovershoottransformdamped: full + (v−full)·.250—both ends
.scrimdrag / snapopacitymax(0,(v−half)/(full−half))·.380 / 480ms--springpointer-events on only above half+10px
.pinpressedtransform1 → scale(1.18)160ms--ease
.namerow selectedcolor--ink → --accent160ms--ease
chippressedbg/colorwhite/--ink-2 → --ink/white0—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 cursor grabbing.
  • Selected row: .row.sel → name in --accent; matching pin aria-pressed="true" and scaled.
  • Row focus-visible: 2px --accent outline, outline-offset:-2px, 8px radius.
  • Pin focus-visible: 2px --accent outline, 2px offset, circular.
  • Chip pressed: --ink background, 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-3 centred 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 is role="button" tabindex="0" with aria-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-2 on white is 6.4:1; --star (#e0a12b) is used only for the bold 13px rating and sits beside text, not alone; --open on 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 on resize.
  • 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
Designed bySusan AcharyaKathmandu

Studied

Don't Board Me

A control. It studies this site for quiet, not for a show.

All sources