08Mobile sheet drag — a bottom sheet mapping drag distance to its height
While the sheet is being dragged, the pointer's travel distance maps directly onto the sheet's height, and on release it interpolates to the nearest snap-point value.
Best forA mobile shopping app's filter bottom sheet, a map app's filter sheet
pointerdownclamp(transform

<main class="stage" id="stage">
<div class="sheet is-demo" id="sheet">
<button class="sheet__handle" id="handle" type="button" aria-label="Drag sheet to close"></button>
<p class="sheet__title">Filter</p>
<div class="sheet__row">Brand</div>
<div class="sheet__row">Price range</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-paper);
.stage {
position: relative;
overflow: hidden;
}
.sheet {
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 150px;
padding: $sp-3 $sp-4;
border-radius: $r-card $r-card 0 0;
background: $color;
color: $subject-ink;
box-shadow: $shadow-raised;
transform: translateY(clamp(0px, var(--drag, 0px), 130px));
touch-action: none;
}
.sheet:not(.is-demo):not(.is-dragging) {
transition: transform $duration $easing;
}
.sheet__handle {
display: block;
width: 36px;
height: 4px;
margin: 0 auto $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba($subject-ink, .28);
padding: 0;
cursor: grab;
}
.sheet__title {
margin: 0 0 $sp-2;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.sheet__row {
padding: $sp-2 0;
border-top: 1px solid rgba($subject-ink, .12);
color: rgba($subject-ink, .8);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 340px) {
.sheet { height: 130px; padding: $sp-2 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sheet { transform: none; }
.sheet:not(.is-demo):not(.is-dragging) { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.sheet {
box-sizing: border-box;
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 150px;
padding: 12px 16px;
border-radius: 16px 16px 0 0;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(clamp(0px, var(--drag, 0px), 130px));
touch-action: none;
}
.sheet:not(.is-demo):not(.is-dragging) {
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sheet__handle {
display: block;
width: 36px;
height: 4px;
margin: 0 auto 12px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.28);
padding: 0;
cursor: grab;
}
.sheet__title {
margin: 0 0 8px;
font-weight: 600;
font-size: 13px;
line-height: 1.3;
}
.sheet__row {
padding: 8px 0;
border-top: 1px solid rgba(23, 20, 26, 0.12);
color: rgba(23, 20, 26, 0.8);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 340px) {
.sheet {
height: 130px;
padding: 8px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sheet {
transform: none;
}
.sheet:not(.is-demo):not(.is-dragging) {
transition: none;
}
}// 포인터가 움직인 거리(px)를 --drag 커스텀 속성에 담고, transform 은 CSS clamp() 로 0~최대값 사이만 움직이게 묶는다.
// 실제 드래그: pointerdown·pointermove·pointerup. 데모는 같은 --drag 값을 시드 고정 시퀀스로 흔들어 재생한다.
(function () {
var sheet = document.getElementById('sheet');
var handle = document.getElementById('handle');
if (!sheet || !handle) return;
var MAX_DRAG = 130;
var dragging = false;
var startY = 0;
function setDrag(px) {
sheet.style.setProperty('--drag', Math.max(0, Math.min(MAX_DRAG, px)) + 'px');
}
handle.addEventListener('pointerdown', function (e) {
dragging = true;
startY = e.clientY;
sheet.classList.remove('is-demo');
sheet.classList.add('is-dragging');
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) {
if (!dragging) return;
setDrag(e.clientY - startY);
});
function release() {
if (!dragging) return;
dragging = false;
sheet.classList.remove('is-dragging');
setDrag(0);
}
handle.addEventListener('pointerup', release);
handle.addEventListener('pointercancel', release);
// 자동 시연: 사람 드래그와 같은 --drag 값을 고정 시퀀스로 움직인다 (실제 pointermove 는 보내지 않는다)
function ease(t) { return t < .5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
function autoLoop(ts) {
if (!sheet.classList.contains('is-demo')) return;
var t = (ts % 2000) / 2000;
var px = t < .5 ? ease(t / .5) * MAX_DRAG : (1 - ease((t - .5) / .5)) * MAX_DRAG;
setDrag(px);
requestAnimationFrame(autoLoop);
}
requestAnimationFrame(autoLoop);
})();import { useEffect, useRef } from "react";
import styles from "./MobileSheetDrag.module.scss";
// 포인터가 움직인 거리(px)를 --drag 커스텀 속성에 담고, transform 은 CSS clamp() 로 0~최대값 사이만 움직이게 묶는다.
// 실제 드래그: pointerdown·pointermove·pointerup·pointercancel. 데모는 같은 --drag 값을 시드 고정 시퀀스로 흔들어 재생한다.
const MAX_DRAG = 130;
function ease(t: number) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
export default function MobileSheetDrag() {
const sheetRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const sheet = sheetRef.current;
const handle = handleRef.current;
if (!sheet || !handle) return;
let dragging = false;
let startY = 0;
let rafId = 0;
function setDrag(px: number) {
sheet!.style.setProperty("--drag", Math.max(0, Math.min(MAX_DRAG, px)) + "px");
}
function onPointerDown(e: PointerEvent) {
dragging = true;
startY = e.clientY;
sheet!.classList.remove(styles["is-demo"]);
sheet!.classList.add(styles["is-dragging"]);
handle!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
setDrag(e.clientY - startY);
}
function release() {
if (!dragging) return;
dragging = false;
sheet!.classList.remove(styles["is-dragging"]);
setDrag(0);
}
handle.addEventListener("pointerdown", onPointerDown);
handle.addEventListener("pointermove", onPointerMove);
handle.addEventListener("pointerup", release);
handle.addEventListener("pointercancel", release);
function autoLoop(ts: number) {
if (!sheet!.classList.contains(styles["is-demo"])) return;
const t = (ts % 2000) / 2000;
const px = t < 0.5 ? ease(t / 0.5) * MAX_DRAG : (1 - ease((t - 0.5) / 0.5)) * MAX_DRAG;
setDrag(px);
rafId = requestAnimationFrame(autoLoop);
}
rafId = requestAnimationFrame(autoLoop);
return () => {
handle.removeEventListener("pointerdown", onPointerDown);
handle.removeEventListener("pointermove", onPointerMove);
handle.removeEventListener("pointerup", release);
handle.removeEventListener("pointercancel", release);
cancelAnimationFrame(rafId);
};
}, []);
return (
<main className={styles.stage}>
<div ref={sheetRef} className={`${styles.sheet} ${styles["is-demo"]}`}>
<button
ref={handleRef}
className={styles.sheet__handle}
type="button"
aria-label="시트 드래그해서 닫기"
/>
<p className={styles.sheet__title}>필터</p>
<div className={styles.sheet__row}>브랜드</div>
<div className={styles.sheet__row}>가격대</div>
</div>
</main>
);
}08 Mobile sheet drag — a bottom sheet mapping drag distance to its height — Measured area 44.842% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 9 Filter UI Patterns (Panel Slide to Chip Remove)
The post this demo lives in
9 Filter UI Patterns (Panel Slide to Chip Remove)Filter ui hides several small jobs. One of nine recomputes chip-row width once a chip disappears, one scales a badge by See all demos in this post
If you like this effect - similar demos
Transitions07 Swipe-follow toggle — while a horizontal drag-distance value grows, the highlight's position value follows toward the side tab by that same ratioCSS Bottom Navigation Bar: 9 Transition Patterns
Transitions02 Bottom sheet9 CSS Modal Animation Patterns, No Library Needed
Transitions09 Mobile full sheet9 CSS Dropdown Menu Animation—No Library, Pure CSS