08モバイルシートドラッグ — 移動距離の値を高さにマッピングするボトムシート
シートを引いている状態になると、ポインターが移動した距離の値をシートの高さの値にそのままマッピングして追従し、手を離した状態になると近い停止位置の値へ補間されます。
使いどころモバイルショッピングアプリのフィルターボトムシート、地図アプリのフィルターシート
pointerdownclamp(transform

<main class="stage" id="stage">
<div class="sheet is-demo" id="sheet">
<button class="sheet__handle" id="handle" type="button" aria-label="シートをドラッグして閉じる"></button>
<p class="sheet__title">フィルター</p>
<div class="sheet__row">ブランド</div>
<div class="sheet__row">価格帯</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 モバイルシートドラッグ — 移動距離の値を高さにマッピングするボトムシート — 計測 累積面積 44.842% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 絞り込み ui9選 — パネルスライドからチップ削除まで
このデモが属する記事
絞り込み ui9選 — パネルスライドからチップ削除まで絞り込み uiは、実は一つの動きではなく複数の小さな計算でできています。9つのうち1つはチップが消えた分だけ残るチップの合計幅を求め直して詰め、1つはアクティブなフィルター数の変化量でバッジを膨らませてから戻し、もう1つは2つのスライダー値 この記事のデモをすべて見る


