Design Lounge
Install

Nº 650 of 677

Throwable sheet with spring detents

A map app's bottom sheet you fling between three detents: release velocity picks the stop and a hand-rolled spring overshoots and settles. Filter chips flick away.

Preview

Open full size
390 × 844 · 21.2 KB · 294 lines

Use it

Free to use · credit appreciated

1 Once, in your project · Node 22.20+

npx skills add SusanAcharya/Design-Lounge

2 Then ask

Using the Design Lounge skill, build "Throwable sheet with spring detents" (piece motion-spring-throw-sheet) 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

243 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 home screen of “Nightjar”, an invented late-night food map for the fictional harbour town of Portmore. A drawn SVG street map fills the screen; a warm off-white bottom sheet lists “14 kitchens open past 11”. The sheet has three detents (full, half, peek) and you throw it: the speed of your finger at release decides where it lands, and a spring you integrate yourself, not a CSS curve, carries that speed into a small overshoot and a settle. The filter chips in the sheet are throwable too: flick one up and it flies off spinning, the row closes the gap, and Undo springs it back in. iOS 26-ish language: glass search pill, 22px sheet radius, spring motion. Worth copying: velocity projection to pick the detent, and handing the release velocity to the spring so the motion is continuous with the hand.

Reference behaviour

  1. First frame, 390 × 844 (the Lounge draws the status bar). Full-bleed SVG map: beige land #ECE6DA, blocks #E2DACB, a river #9FC3CC crossing at y ≈ 270–380, a park, white roads with #D3C9B7 casing, two district labels (“OLD QUAY”, “ROPEWORKS”, 10px/700, 2px tracking, #4F493F), five coral pins numbered 1–5 and a blue “you” dot with a 22px halo.
  2. Top, at 58px: a 48px glass search pill “Search Portmore” and a 48px ink avatar “IV”. A 48px round locate button floats 64px above the sheet’s top edge on the right.
  3. The sheet rests at the half detent (top = 48% of viewport height, 404px at 844). Contents: 24px handle zone with a 40 × 5 grabber; heading “14 kitchens open past 11” (22/800) with “Fri 22:48” at right; sub-line “Within 15 minutes on foot of Harbour Street”; a horizontally scrolling chip row (“Open now” and “Noodles” selected, ink fill; “Under £15”, “Takeaway”, “Vegetarian”, “Seats 4+” outlined); a hint “Flick a filter up to drop it” with an up arrow; then seven place rows.
  4. Press anywhere on the sheet (outside chips) and drag: the sheet top follows the finger 1:1. Past the full or peek detent it rubber-bands: limit ± (1 − 1/(d·0.55/200 + 1))·200.
  5. Release: compute velocity from the last 80ms of pointer samples. Project the resting point y + v · 0.2s, pick the nearest detent to that projection. If |v| > 900 px/s and the nearest is the current detent, move one detent in the throw’s direction anyway.
  6. The sheet then runs a spring from its current top to the chosen detent, starting with the release velocity: stiffness 420, damping 28, mass 1 (damping ratio 0.68). A firm upward throw from half overshoots full by about 10px (top reaches ≈ 54px), then settles at 64px in roughly 450ms.
  7. The map scrim (#1D1B18) fades from 0 at half to 0.32 at full, driven by the live sheet position, not a separate animation. The locate button rides 64px above the sheet and hides above the half detent.
  8. At full (top 64px) the list becomes scrollable; dragging the header still moves the sheet. At half and peek the list does not scroll; the whole sheet is the drag handle.
  9. Tap the grabber (movement under 6px): half goes to full; full or peek go to half.
  10. Chips: tap toggles selection and the heading count updates (e.g. deselecting “Noodles” reads “16 kitchens open past 11”).
  11. Drag a chip vertically (more than 8px, mostly vertical): it follows the finger and tilts dx · 0.08°. Release beyond 70px or faster than 700 px/s: it keeps flying at max(1600, |v|) px/s in that direction, spins 260°/s and fades over ~310ms. Its slot then collapses to 0 width in 280ms on the iOS curve; the hint fades away for good.
  12. Release a chip short of that: it springs home (k 600, c 24, m 1) from where it is, with the release velocity.
  13. A toast slides in at top 118px: ink pill, “Removed “Noodles”” and an “Undo” button. It stays 4s.
  14. Undo: the slot reopens, the chip appears 56px above its place and springs down into it with 900 px/s of initial velocity, so it lands with one small bounce; the count restores.
  15. Keyboard: the grabber is a button; ArrowUp and ArrowDown move one detent (with a synthetic 1400 px/s so the spring feels the same), Enter toggles half/full. On a chip, Delete or Backspace throws it upward and moves focus to Undo.
  16. Reduced motion: no springs, no overshoot. The sheet moves with a 200ms standard-ease transition; chips disappear with no flight and the slot closes at once.

Structure

390 × 844
┌───────────────────────────────────────┐
│ 54 status (Lounge)                    │
│ [ ⌕ Search Portmore          ] (IV)   │ 58–106  .top, fixed
│                                       │
│      SVG map · pins 1–5 · you dot     │ full-bleed, preserveAspectRatio slice
│  ~~~~~~~~ river ~~~~~~~~~~      (⌖)   │ locate: top = sheetTop − 64
├───────────────────────────────────────┤ ◄ sheet top: 64 | 405 | 668
│               ▬▬▬▬ (40×5)             │ handle 24
│ 14 kitchens open past 11   Fri 22:48  │ h1 22/800
│ Within 15 minutes on foot of …        │
│ (Open now)(Under £15)(Noodles)(Take…→ │ chips 40 tall, x-scroll
│ ↑ Flick a filter up to drop it        │
│ ─────────────────────────────────────  │
│ [tile] Salt Lamp Ramen        4.7     │ rows 52 tile, 12 pad
│        Tonkotsu, gyoza …  till 01:00  │
│ …                                     │
└───────────────────────────────────────┘
  • <svg class="map" aria-hidden="true">: fixed, inset 0, viewBox 390 × 844.
  • .scrim: fixed, ink, opacity bound to sheet position.
  • <header class="top">: search pill (role="search") and avatar <button>.
  • <button class="locate">: fixed, its top follows the sheet.
  • <div class="toast" role="status" aria-live="polite"> with an Undo <button>; visibility:hidden when closed.
  • <section class="sheet" aria-label="Kitchens near you">: fixed, left/right/bottom: 0, top driven by JS, max-width 430px centred.
    • .grab: <button class="handle"> + heading row + sub-line.
    • .chips role="group": each chip is a .cw wrapper (so its width can collapse) holding <button class="chip" aria-pressed>.
    • .list <ul> of rows: tile, name and detail, rating and closing time.

Drive the sheet with top (bottom stays 0) so the list’s scroll box is always exactly the visible part; nothing sits below the screen edge.

Tokens

:root {
  /* map */
  --land: #ece6da;
  --block: #e2dacb;
  --water: #9fc3cc;
  --water-2: #8ab4bf;     /* ferry route dashes */
  --park: #c9d9b4;
  --road: #fbfaf7;
  --road-edge: #d3c9b7;
  /* surfaces and ink */
  --sheet: #fbfaf7;
  --line: #e4ddd0;        /* row hairlines */
  --chip-line: #8c8577;   /* 3.6:1 on sheet */
  --grabber: #a39b8c;
  --ink: #1d1b18;
  --ink-2: #595349;       /* 7.0:1 on sheet */
  --accent: #c23e26;      /* pins, closing times (5.0:1 on sheet) */
  --you: #2a64c8;         /* location dot, focus ring */
  --toast-action: #ffd7cc;
  /* tiles */
  --tile-coral: #f6ddd5; --tile-sage: #dfe8d2; --tile-sky: #d9e6ea; --tile-sand: #efe2c8; --tile-lilac: #e7dcef;
  /* type */
  --sans: "Plus Jakarta Sans", system-ui, sans-serif;
  --fs-title: 22px; --fs-name: 16px; --fs-body: 15px; --fs-chip: 14px; --fs-meta: 13px; --fs-hint: 12px;
  /* space and shape */
  --gutter: 20px; --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
  --r-sheet: 22px; --r-chip: 20px; --r-tile: 14px; --r-toast: 16px; --r-pill: 24px;
  --chip-h: 40px; --tile: 52px; --control: 48px;
  /* shadows */
  --sh-sheet: 0 -1px 0 rgba(29,27,24,.08), 0 -12px 40px rgba(29,27,24,.16);
  --sh-float: 0 8px 24px rgba(29,27,24,.12);
  --sh-toast: 0 12px 30px rgba(29,27,24,.28);
  /* detents (top of sheet) */
  --detent-full: 64px;
  --detent-half: 48vh;
  --detent-peek: calc(100vh - 176px);
  /* sheet spring */
  --spring-k: 420;        /* stiffness, N/m */
  --spring-c: 28;         /* damping, N·s/m → ζ = 28 / (2√420) = 0.68 */
  --spring-m: 1;          /* mass, kg */
  --spring-substep: 4.17ms; /* 1/240 s */
  --spring-rest-v: 4;     /* px/s */
  --spring-rest-x: 0.4px;
  --projection: 0.2s;     /* y + v·0.2 picks the detent */
  --fling-skip: 900;      /* px/s forces one detent of travel */
  --rubber: 0.55;         /* rubber-band coefficient, 200px dimension */
  /* chip spring and throw */
  --chip-k: 600; --chip-c: 24; --chip-m: 1;
  --chip-throw-dist: 70px; --chip-throw-v: 700; /* px/s */
  --chip-fly-min: 1600;   /* px/s */
  --chip-spin: 260deg;    /* per second */
  --chip-fade: 3.2;       /* opacity per second */
  /* motion */
  --ios: cubic-bezier(.32,.72,0,1);
  --ease: cubic-bezier(.2,.7,.2,1);
  --d-collapse: 280ms; --d-toast: 320ms; --d-toast-fade: 200ms; --d-rm: 200ms;
  --toast-hold: 4000ms;
}

Typography

RoleFamilySizeWeightLine-heightTrackingCase
Sheet titlePlus Jakarta Sans22px8001.15−0.02emSentence
Place namePlus Jakarta Sans16px7001.4−0.01emTitle
Search, bodyPlus Jakarta Sans15px5001.40Sentence
ChipPlus Jakarta Sans14px60010Sentence, nowrap
Meta, time, ratingPlus Jakarta Sans13px600–7001.40Sentence
HintPlus Jakarta Sans12px6001.40Sentence
Map districtPlus Jakarta Sans10px70012pxUpper
Pin numeralPlus Jakarta Sans12px80010—

Motion

ThingTriggerPropertyFrom → toPhysics / durationReduced motion
Sheet dragpointer movetopfinger, rubber-band past ends1:1same
Sheet settlerelease, key, taptopcurrent → detentspring k 420, c 28, m 1, v0 = release velocity200ms --ease transition
Scrimsheet topopacity0 (half) → 0.32 (full)bound, no own timingsame
Locate buttonsheet toptop, opacitysheetTop − 64; 0 above half − 40boundsame
Chip dragvertical pointer movetranslateY, rotatefinger; tilt dx·0.08°1:1same
Chip throwrelease > 70px or > 700px/stranslateY, rotate, opacitykeeps going at ≥ 1600px/s, +260°/s, −3.2/s opacityrAF, ~310mshidden at once
Chip slotafter throw / undowidth, margin-rightmeasured → 0 / 0 → measured280ms --ios1ms
Chip returnshort release / undotranslateYdy → 0 (undo: −56 → 0, v0 900)spring k 600, c 24, m 1jump
ToastthrowtranslateY, opacity−12px, 0 → 0, 1320ms --ios / 200ms1ms
Hintfirst throwopacity1 → 0, then hidden240ms --ease1ms

States

  • Sheet detents: full (64px, list scrolls, scrim 0.32), half (48vh, default), peek (viewport − 176px, header and chips only).
  • Dragging: cursor grabbing on the header; springs cancelled the moment a finger lands, so you can catch a moving sheet.
  • Chip selected: ink fill #1D1B18, text #FBFAF7, border ink. Unselected: sheet fill, 1.5px #8C8577 border.
  • Chip in flight: follows finger; after throw it is invisible and its slot visibility:hidden.
  • Toast open: visible for 4s; closed it is visibility:hidden after its 320ms exit.
  • Focus-visible: 3px #2A64C8 outline, 2px offset, 12px radius, on the grabber, chips, avatar, locate and Undo.
  • Empty: not reachable here; if every chip is thrown, the row is empty and the hint stays gone.

Accessibility

  • The grabber <button> is labelled “Resize list. Now half height. Arrow keys change height.” and the label updates per detent; a polite visually hidden region says “List full”, “List half” or “List peek”.
  • ArrowUp / ArrowDown on the grabber move one detent; Enter or Space toggles half/full. These run the same spring as a throw.
  • Chips are toggle buttons with aria-pressed; the group label tells screen-reader users that Delete drops a filter. Delete or Backspace throws the focused chip and moves focus to Undo; Undo returns focus to the restored chip.
  • The toast is role="status" and aria-live="polite".
  • Map is decorative (aria-hidden); the list carries the same places.
  • Hit targets: chips 40px tall, grabber zone full width × 24px plus the 70px header, controls 48px.
  • Contrast: body #595349 on #FBFAF7 is 7.0:1, closing times #C23E26 5.0:1, toast text #FBFAF7 on #1D1B18 16:1.

Responsive rules

  • Detents are expressed against viewport height (64px, 48%, H − 176px) and recomputed on resize; the sheet keeps its current detent.
  • At 360 wide the chip row simply scrolls further; names in rows truncate before the rating column (the rating column never wraps).
  • Above 430px wide (tablet, desktop preview), the sheet, top bar and toast centre at max 430px while the map stays full-bleed.
  • Keep 54px clear at the top: the search row starts at 58px.

Acceptance checklist

Always

  • Three detents; on release the detent is chosen from y + v·0.2s, not from the release position alone.
  • A throw faster than 900px/s always travels at least one detent in its direction.
  • The settle is a hand-integrated spring (k 420, c 28, m 1) seeded with the release velocity; a hard upward throw visibly overshoots full by 6–12px.
  • Grabbing the sheet mid-spring stops it under the finger with no jump.
  • Rubber-banding beyond full and peek; never a hard stop.
  • Scrim opacity and the floating button are bound to the live sheet position.
  • Chips: vertical flick past 70px or 700px/s throws; shorter springs back; tap toggles.
  • Undo restores the chip with a spring landing, and focus returns to it.
  • Keyboard can reach every detent and remove a chip.
  • Reduced motion: no overshoot, no flight; 200ms transitions only.
  • Closed toast is visibility:hidden; scrollWidth stays 390.

This demo

  • Heading reads “14 kitchens open past 11”, time “Fri 22:48”, seven rows starting “Salt Lamp Ramen”.
  • Toast copy “Removed “Noodles”” with “Undo”.
  • Detents at 64 / 405 / 668px on an 844px screen.

Implementation notes

1. The spring. Semi-implicit Euler in fixed 1/240s substeps is stable at these constants and frame-rate independent. Cap dt at 32ms so a backgrounded tab does not explode it.

const step = (s, dt) => {              // s = { x, v, to, k, c, m }
  for (let n = Math.ceil(dt / (1/240)), h = dt / n; n--;) {
    s.v += ((-s.k * (s.x - s.to) - s.c * s.v) / s.m) * h;
    s.x += s.v * h;
  }
  return Math.abs(s.v) < 4 && Math.abs(s.x - s.to) < 0.4;   // at rest
};

2. Velocity and projection. Keep only samples from the last 80ms so a pause before release reads as zero velocity. Then project and pick.

const pick = (vel) => {
  const proj = y + vel * 0.2;
  let best = 0;
  det.forEach((d, i) => { if (Math.abs(d - proj) < Math.abs(det[best] - proj)) best = i; });
  if (Math.abs(vel) > 900 && best === idx) best = clamp(idx + Math.sign(vel), 0, 2);
  return best;
};
// on release: target = det[pick(v)]; spring.v = v;  ← carry the hand's speed in

3. Throwable chips inside a horizontal scroller. Give chips touch-action: pan-x so the browser keeps horizontal scrolling, and only capture the pointer once the gesture is clearly vertical (|dy| > 8 && |dy| > |dx|). Swallow the click that follows a drag, or every throw also toggles the filter. Collapse the chip’s wrapper, not the chip, so the flying chip keeps its size:

w.style.width = w.offsetWidth + 'px'; w.offsetWidth;    // lock, then reflow
w.style.width = '0px'; w.style.marginRight = '-8px';    // eat the 8px gap
w.style.visibility = 'hidden';

Common mistakes: animating the sheet with a CSS transition (it drops the release velocity, so every throw lands the same), choosing the detent by nearest position (a fast short flick goes nowhere), and translating the sheet off-screen so its list text sits below the viewport.

Details

Palette
Type
Plus Jakarta Sans
Motion
Rich motion
Build
Multi-step
Designed bySusan AcharyaKathmandu

Studied

UseArticle

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

All sources

Install once. Ask for anything.

The MCP server gives your agent 600+ pieces, the themes and the review tools. It installs the skill too. Free, no account.

claude mcp add -s user design-lounge -- npx -y design-lounge-mcp@latest

Claude Code. For Cursor, Codex, VS Code, ChatGPT and the skill alone, see Install.