Drawer Snap
Drawer Snap
Bottom sheet that opens with spring easing and snaps closed when dragged past a threshold. Pointer capture tracks drag on the handle. Backdrop fades proportional to drag progress.
Dependencies
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Drawer Snap — Motion Recipe</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/motion@11.11.13/dist/motion.js"></script>
<style>
.drawer {
transform: translateY(100%);
touch-action: none;
}
.drawer-backdrop {
opacity: 0;
pointer-events: none;
}
.drawer-backdrop.is-open {
opacity: 1;
pointer-events: auto;
}
</style>
</head>
<body class="bg-slate-950 text-slate-200 font-sans overflow-hidden" style="height: 100dvh;">
<!-- Page content (shows through backdrop) -->
<div class="mx-auto max-w-3xl px-8 pt-20">
<span class="mb-6 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Drawer Snap</span>
<h1 class="mb-4 text-4xl font-black leading-tight text-slate-100">Bottom sheet<br>with snap.</h1>
<p class="mb-12 max-w-sm text-base leading-relaxed text-slate-400">Drag the handle or swipe down to dismiss. The sheet snaps between open and closed — no half-open state. Spring easing on both directions.</p>
<div class="flex flex-wrap gap-3">
<button id="open-filter"
class="rounded-xl bg-violet-600 px-6 py-3 font-semibold text-white transition-colors hover:bg-violet-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Open filters
</button>
<button id="open-share"
class="rounded-xl border border-slate-700 bg-slate-900 px-6 py-3 font-semibold text-slate-300 transition-colors hover:bg-slate-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950">
Share sheet
</button>
</div>
</div>
<!-- Backdrop -->
<div id="backdrop"
class="drawer-backdrop fixed inset-0 bg-slate-950/60 backdrop-blur-sm z-40 transition-opacity duration-300"
aria-hidden="true">
</div>
<!-- Drawer -->
<div id="drawer"
class="drawer fixed bottom-0 left-0 right-0 z-50 rounded-t-3xl border-t border-white/8 bg-slate-900 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="drawer-title"
style="max-height: 85dvh;">
<!-- Handle -->
<div id="drawer-handle"
class="flex cursor-grab items-center justify-center pb-2 pt-3 active:cursor-grabbing"
aria-label="Drag to close">
<div class="h-1 w-10 rounded-full bg-slate-600"></div>
</div>
<!-- Content -->
<div id="drawer-content" class="overflow-y-auto px-6 pb-8" style="max-height: calc(85dvh - 40px);">
<h2 id="drawer-title" class="mb-6 text-xl font-bold text-slate-100">Filters</h2>
<div class="space-y-4">
<!-- Filter section -->
<div>
<h3 class="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">Category</h3>
<div class="flex flex-wrap gap-2">
<button class="rounded-full bg-violet-600 px-4 py-1.5 text-sm font-medium text-white">All</button>
<button class="rounded-full border border-slate-700 bg-transparent px-4 py-1.5 text-sm font-medium text-slate-400 transition-colors hover:border-slate-500 hover:text-slate-300">Design</button>
<button class="rounded-full border border-slate-700 bg-transparent px-4 py-1.5 text-sm font-medium text-slate-400 transition-colors hover:border-slate-500 hover:text-slate-300">Engineering</button>
<button class="rounded-full border border-slate-700 bg-transparent px-4 py-1.5 text-sm font-medium text-slate-400 transition-colors hover:border-slate-500 hover:text-slate-300">Product</button>
</div>
</div>
<div class="h-px bg-slate-800"></div>
<div>
<h3 class="mb-3 text-xs font-semibold uppercase tracking-widest text-slate-500">Sort by</h3>
<div class="space-y-2">
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="sort" value="recent" checked class="accent-violet-500">
<span class="text-sm text-slate-300">Most recent</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="sort" value="popular" class="accent-violet-500">
<span class="text-sm text-slate-300">Most popular</span>
</label>
<label class="flex cursor-pointer items-center gap-3">
<input type="radio" name="sort" value="alpha" class="accent-violet-500">
<span class="text-sm text-slate-300">Alphabetical</span>
</label>
</div>
</div>
<div class="h-px bg-slate-800"></div>
<button
class="w-full rounded-2xl bg-violet-600 py-3.5 font-semibold text-white transition-colors hover:bg-violet-500 focus-visible:outline-none"
onclick="closeDrawer()">
Apply filters
</button>
</div>
</div>
</div>
<script>
var { animate } = Motion;
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var OPEN_EASING = [0.16, 1, 0.3, 1];
var CLOSE_EASING = [0.32, 0, 0.67, 0];
var drawer = document.getElementById('drawer');
var backdrop = document.getElementById('backdrop');
var handle = document.getElementById('drawer-handle');
var isOpen = false;
function openDrawer() {
isOpen = true;
backdrop.classList.add('is-open');
drawer.removeAttribute('aria-hidden');
if (!reduced) {
animate(drawer, { y: ['100%', '0%'] }, { duration: 0.42, easing: OPEN_EASING });
} else {
drawer.style.transform = 'translateY(0)';
}
// Focus first interactive element
setTimeout(function() {
var firstFocusable = drawer.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) firstFocusable.focus();
}, 100);
}
function closeDrawer() {
isOpen = false;
backdrop.classList.remove('is-open');
drawer.setAttribute('aria-hidden', 'true');
if (!reduced) {
animate(drawer, { y: ['0%', '100%'] }, { duration: 0.3, easing: CLOSE_EASING });
} else {
drawer.style.transform = 'translateY(100%)';
}
}
document.getElementById('open-filter').addEventListener('click', openDrawer);
document.getElementById('open-share').addEventListener('click', openDrawer);
backdrop.addEventListener('click', closeDrawer);
// Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isOpen) closeDrawer();
});
// Drag to dismiss
(function() {
var startY = 0;
var currentY = 0;
var dragging = false;
var SNAP_THRESHOLD = 80; // px — if dragged past this, dismiss
handle.addEventListener('pointerdown', function(e) {
if (!isOpen) return;
dragging = true;
startY = e.clientY;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function(e) {
if (!dragging) return;
currentY = Math.max(0, e.clientY - startY);
drawer.style.transform = 'translateY(' + currentY + 'px)';
// Dim backdrop proportional to drag
var progress = currentY / window.innerHeight;
backdrop.style.opacity = 1 - progress;
});
handle.addEventListener('pointerup', function() {
if (!dragging) return;
dragging = false;
if (currentY > SNAP_THRESHOLD) {
closeDrawer();
} else {
// Snap back open
if (!reduced) {
animate(drawer, { y: [currentY + 'px', '0%'] }, { duration: 0.3, easing: OPEN_EASING });
} else {
drawer.style.transform = 'translateY(0)';
}
backdrop.style.opacity = '';
}
currentY = 0;
});
}());
</script>
</body>
</html>
Open: animate(drawer, { y: ['100%', '0%'] }) with spring easing [0.16, 1, 0.3, 1] — fast deceleration that reads as physical inertia stopping against a surface.
Close: animate(drawer, { y: ['0%', '100%'] }) with [0.32, 0, 0.67, 0] — ease-in, which reads as gravity pulling the sheet down. The direction change in easing (decelerate on enter, accelerate on exit) creates physical plausibility.
Drag to dismiss: Uses setPointerCapture so the handle tracks the pointer even when it moves off the element. During drag, transform: translateY(Npx) is set directly (no Motion One) for zero-latency tracking. The backdrop opacity is updated proportionally: 1 - (currentY / windowHeight). On release, if currentY > SNAP_THRESHOLD (80px), the close animation fires; otherwise a spring snaps the drawer back to y: 0.
Accessibility: role="dialog" with aria-modal="true" and aria-labelledby. aria-hidden="true" is set on the drawer while closed and removed on open. Focus moves to the first focusable element inside the drawer on open (150ms delay to let the animation start). Escape key closes the drawer. Backdrop click closes it.
prefers-reduced-motion skips animate() calls and sets transform directly — the drawer still opens, closes, and tracks drag; only the spring animation is absent.