DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

A bottom sheet is one translateY — snap points are fractions of the frame, and release projects the flick to the nearest one

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); }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Scrim, scroll-lock, and the dialog contract

The scrim opacity is derived from the position1 − 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();
Enter fullscreen mode Exit fullscreen mode

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)