A share tray, an AirDrop puck, a map's place card — the panel that slides up from the bottom edge and rests at a few heights. I always half-assumed it needed a gesture library. It doesn't. The sheet is a full-height panel positioned by a single translateY, the snap points are fractions of the frame, and momentum is one multiply and a nearest-of search. Once I built it from scratch the whole recipe fit in my head. Here's the version I keep now.
The sheet is a full-height panel pushed down
The sheet spans the whole frame height and simply sits shoved down out of view with translateY(100%). That way "how open it is" is one number, and the bottom corners never peek in because they're always below the fold.
.sheet { transform: translateY(100%); }
.sheet.animate { transition: transform .38s cubic-bezier(.32,.72,0,1); }
Snap points are fractions, never pixels
Store pixel heights and they break on every screen size. Store each snap as the fraction of the frame that should stay visible — peek 0.28, half 0.55, full 0.92 — and turn it into a translate on the fly. Showing fraction f means translating down by (1 − f) × H; the dismissed position is just H.
const SNAPS = { peek: 0.28, half: 0.55, full: 0.92 };
const yFor = f => (1 - f) * H; // fraction visible -> translateY px
One drag path for mouse and touch
Pointer events unify mouse, touch and pen, so the gesture is written once. On pointerdown remember the start Y and start translate, drop the .animate class so it tracks the finger 1:1, and call setPointerCapture so the moves keep coming even if the finger slips off. touch-action:none stops the browser hijacking the gesture to scroll the page.
drag.addEventListener("pointermove", e => {
let y = startCur + (e.clientY - startY);
y = Math.max(yFor(topFraction()), Math.min(H, y)); // clamp: top snap .. dismissed
setY(y, false); // instant + repaints scrim
});
Velocity, then project-and-snap
A snap that ignores your speed feels dead. Track velocity by keeping only the last position/timestamp sample and dividing the deltas — px per millisecond, positive down. On release, a hard flick dismisses or jumps to the top; otherwise project where momentum lands and pick whichever target (each enabled snap, plus the dismiss line at H) is closest. Momentum without physics — one multiply and a nearest-of search.
const projected = cur + velocity * 120; // where the flick was headed
const [name] = targets.reduce((a, b) =>
Math.abs(b[1]-projected) < Math.abs(a[1]-projected) ? b : a);
Scrim, scroll-lock, and the dialog contract
The scrim opacity is derived from the position — 1 − y/H scaled down — so the backdrop dims and lightens live as you drag, with no extra code, and it doubles as the tap-to-dismiss target.
function paintScrim(){ // opacity tracks position
const vis = Math.max(0, 1 - cur / H); // 1 = full, 0 = dismissed
scrim.style.opacity = scrimOn ? vis * 0.55 : 0;
}
// open: screen.style.overflow = "hidden"; lastFocused = document.activeElement;
// close: screen.style.overflow = "auto"; lastFocused.focus();
While the sheet is up I lock the background scroll (overflow:hidden on the scroller) so a swipe inside can't chain to the page. And because a sheet is a modal, it owes the full contract: role="dialog", aria-modal, aria-labelledby, a Tab focus-trap, focus restore on close, Esc to dismiss, and ↑/↓ on the grabber to step between snaps for keyboard users. Respect prefers-reduced-motion and the long slide collapses to an instant snap.
That's the whole thing: one translateY, snaps as fractions, a pointer drag, velocity-projected nearest-snap, a scrim that tracks position, a scroll-lock, and the honest dialog contract. No library — just a panel and a bit of math.
Open it, drag the grabber between peek/half/full, and fling it down to dismiss:
https://dev48v.infy.uk/design/day49-bottom-sheet.html
Top comments (0)