UI Components Hard
Bottom-sheet Drag
Drag a bottom sheet with Pointer Events, snapping it open or closed with momentum.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
*,
*::before,
*::after { box-sizing: border-box; }
:root {
--bg: #0b1020;
--panel: #121a2d;
--sheet: #1a2438;
--line: #2b3d62;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78a9ff;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 2.5rem);
}
button { font: inherit; }
.demo {
width: min(920px, 100%);
margin: auto;
display: grid;
place-items: center;
}
.stage { display: grid; gap: 0.9rem; justify-items: center; }
/* Phone frame — the sheet is positioned against this, not the window. */
.phone {
position: relative;
width: min(360px, 92vw);
height: min(620px, 76vh);
overflow: hidden;
border-radius: 28px;
border: 1px solid var(--line);
background:
radial-gradient(120% 70% at 50% 0%, #1d2a44 0%, transparent 60%),
var(--panel);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.55);
touch-action: none;
}
.page-content { padding: 2rem 1.5rem; }
.page-content h1 { margin: 0 0 0.5rem; font-size: 1.35rem; letter-spacing: -0.01em; }
.page-content p { margin: 0 0 1rem; color: var(--muted); font-size: 0.875rem; }
.hint { font-size: 0.75rem !important; }
.open-btn {
border: 1px solid var(--line);
background: #212c46;
color: var(--text);
font-size: 0.875rem;
padding: 0.6rem 1rem;
border-radius: 10px;
cursor: pointer;
}
.open-btn:hover { border-color: var(--accent); }
.open-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.scrim {
position: absolute;
inset: 0;
background: #000;
opacity: 0;
pointer-events: none;
}
.scrim.interactive { pointer-events: auto; }
/* Sheet: full-height layer translated down; JS drives --y in px. */
.sheet {
position: absolute;
inset: 0;
transform: translate3d(0, var(--y, 100%), 0);
display: flex;
flex-direction: column;
border-radius: 22px 22px 0 0;
background: var(--sheet);
border-top: 1px solid var(--line);
box-shadow: 0 -18px 50px rgba(0, 0, 0, 0.6);
will-change: transform;
}
.sheet.animating { transition: transform 340ms cubic-bezier(0.22, 0.9, 0.28, 1); }
.handle-area {
flex: none;
padding: 0.75rem 0 0.5rem;
display: grid;
place-items: center;
cursor: grab;
touch-action: none;
}
.handle-area:active { cursor: grabbing; }
.handle-area:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -4px;
border-radius: 22px 22px 0 0;
}
.grabber { width: 44px; height: 5px; border-radius: 99px; background: #46557a; }
.handle-area:hover .grabber,
.sheet.dragging .grabber { background: var(--accent); }
.sheet-body { flex: 1; overflow-y: auto; padding: 0.25rem 1.25rem 1.75rem; }
.sheet-body h2 { margin: 0.25rem 0 0.9rem; font-size: 1.05rem; }
.list { list-style: none; margin: 0 0 1.1rem; padding: 0; display: grid; gap: 0.5rem; }
.list li {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.75rem;
border-radius: 12px;
background: #212c46;
border: 1px solid var(--line);
font-size: 0.875rem;
}
.list em { margin-left: auto; font-style: normal; color: var(--muted); font-size: 0.75rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; }
.dot.a { background: #78a9ff; }
.dot.b { background: #5fd0a4; }
.dot.c { background: #f2c14b; }
.dot.d { background: #ef7d7d; }
.note { margin: 0; color: var(--muted); font-size: 0.75rem; }
.readout {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.75rem;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
.sheet.animating { transition-duration: 1ms; }
}/**
* Bottom-sheet drag with Pointer Events.
* - The sheet is a full-height layer; `--y` (px from the top of the frame) drives translate3d.
* - Snap points are fractions of the frame height, expressed as "openness" (0 = closed).
* - Release picks a snap point from position, or from velocity when the gesture is a flick.
*/
const frame = document.getElementById("sheet").parentElement;
const sheet = document.getElementById("sheet");
const handle = document.getElementById("handle");
const scrim = document.getElementById("scrim");
const openBtn = document.getElementById("openBtn");
const readout = document.getElementById("readout");
const SNAPS = [0, 0.45, 0.92]; // openness values
const LABELS = ["closed", "half", "full"];
const FLICK = 0.5; // px per ms
const RUBBER = 0.35; // resistance past the top snap
let height = frame.clientHeight;
let openness = 0; // 0..1 — fraction of the frame the sheet covers
let dragging = false;
let pointerId = null;
let startY = 0;
let startOpenness = 0;
let lastY = 0;
let lastT = 0;
let velocity = 0; // px/ms, positive = moving down
const yFor = (o) => height * (1 - o);
function render() {
sheet.style.setProperty("--y", `${yFor(openness)}px`);
scrim.style.opacity = String(Math.min(openness / 0.92, 1) * 0.55);
scrim.classList.toggle("interactive", openness > 0.02);
sheet.setAttribute("aria-hidden", openness <= 0.02 ? "true" : "false");
const pct = Math.round(openness * 100);
const nearest = LABELS[nearestIndex(openness)];
handle.setAttribute("aria-valuenow", String(pct));
handle.setAttribute("aria-valuetext", `${nearest}, ${pct} percent`);
readout.textContent = `${nearest} · ${pct}%`;
}
function nearestIndex(o) {
let best = 0;
for (let i = 1; i < SNAPS.length; i++) {
if (Math.abs(SNAPS[i] - o) < Math.abs(SNAPS[best] - o)) best = i;
}
return best;
}
function animateTo(target) {
openness = target;
sheet.classList.add("animating");
render();
}
sheet.addEventListener("transitionend", (e) => {
if (e.propertyName === "transform") sheet.classList.remove("animating");
});
/* ---- drag ---- */
handle.addEventListener("pointerdown", (e) => {
if (pointerId !== null) return;
pointerId = e.pointerId;
dragging = true;
height = frame.clientHeight;
startY = lastY = e.clientY;
lastT = e.timeStamp;
velocity = 0;
startOpenness = openness;
handle.setPointerCapture(pointerId);
sheet.classList.remove("animating");
sheet.classList.add("dragging");
e.preventDefault();
});
handle.addEventListener("pointermove", (e) => {
if (!dragging || e.pointerId !== pointerId) return;
const dt = e.timeStamp - lastT;
if (dt > 0) velocity = (e.clientY - lastY) / dt;
lastY = e.clientY;
lastT = e.timeStamp;
let next = startOpenness + (startY - e.clientY) / height;
const max = SNAPS[SNAPS.length - 1];
if (next > max) next = max + (next - max) * RUBBER; // rubber-band over the top
openness = Math.max(0, Math.min(1, next));
render();
});
function endDrag(e) {
if (!dragging || e.pointerId !== pointerId) return;
dragging = false;
sheet.classList.remove("dragging");
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
pointerId = null;
let index = nearestIndex(openness);
if (Math.abs(velocity) > FLICK) {
// Momentum: step one snap point in the direction of travel.
const dir = velocity > 0 ? -1 : 1; // moving down = closing
const from = nearestIndex(startOpenness);
index = Math.max(0, Math.min(SNAPS.length - 1, from + dir));
}
animateTo(SNAPS[index]);
}
handle.addEventListener("pointerup", endDrag);
handle.addEventListener("pointercancel", endDrag);
/* ---- keyboard + buttons ---- */
handle.addEventListener("keydown", (e) => {
const i = nearestIndex(openness);
let target = null;
if (e.key === "ArrowUp" || e.key === "PageUp") target = SNAPS[Math.min(SNAPS.length - 1, i + 1)];
else if (e.key === "ArrowDown" || e.key === "PageDown") target = SNAPS[Math.max(0, i - 1)];
else if (e.key === "Home") target = SNAPS[0];
else if (e.key === "End") target = SNAPS[SNAPS.length - 1];
else if (e.key === "Escape") target = 0;
if (target === null) return;
e.preventDefault();
animateTo(target);
});
openBtn.addEventListener("click", () => {
height = frame.clientHeight;
animateTo(SNAPS[1]);
handle.focus();
});
scrim.addEventListener("click", () => animateTo(0));
window.addEventListener("resize", () => {
height = frame.clientHeight;
render();
});
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bottom-sheet Drag</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Bottom-sheet Drag">
<section class="stage" aria-label="Bottom sheet demo">
<div class="phone">
<div class="page-content">
<h1>Nearby places</h1>
<p>Drag the sheet handle up or down. Release to snap — flick fast for momentum.</p>
<button type="button" class="open-btn" id="openBtn" aria-controls="sheet">Open sheet</button>
<p class="hint">Keyboard: focus the handle, then Arrow Up / Down, Home, End or Esc.</p>
</div>
<div class="scrim" id="scrim"></div>
<div class="sheet" id="sheet" role="dialog" aria-labelledby="sheetTitle" aria-hidden="true">
<div
class="handle-area"
id="handle"
role="slider"
tabindex="0"
aria-label="Sheet height"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-valuetext="closed"
>
<span class="grabber" aria-hidden="true"></span>
</div>
<div class="sheet-body">
<h2 id="sheetTitle">Route options</h2>
<ul class="list">
<li><span class="dot a"></span><strong>Walk</strong><em>18 min · 1.4 km</em></li>
<li><span class="dot b"></span><strong>Bike</strong><em>7 min · 1.6 km</em></li>
<li><span class="dot c"></span><strong>Transit</strong><em>11 min · 2 stops</em></li>
<li><span class="dot d"></span><strong>Drive</strong><em>6 min · traffic</em></li>
</ul>
<p class="note">
Snap points sit at 0 %, 45 % and 92 % of the frame height. A flick faster than the
velocity threshold skips to the next point in the direction of travel.
</p>
</div>
</div>
</div>
<output class="readout" id="readout" aria-live="polite">closed · 0%</output>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function GestureBottomSheet() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Bottom-sheet Drag</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Bottom-sheet Drag
Drag a bottom sheet with Pointer Events, snapping it open or closed with momentum.
Support notes
The examples use Pointer Events, capture, bounded transforms, and small snap thresholds. Production code should also wire keyboard alternatives and reduced-motion preferences.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.