08끌어올리는 바텀시트
시트가 손가락을 따라 실시간으로 오르내리다가 놓으면 가장 가까운 단계로 스냅되고, 아래로 세게 던지면 닫히며 단계 버튼과 Escape 도 같은 단계를 밟습니다.
쓰임지도·음악 플레이어처럼 한 화면에서 단계별로 올려 쓰는 패널을 붙일 때
translateYsnapEscape

<main class="stage">
<div class="bs is-demo" id="bs-root">
<div class="bs__frame" id="bs-frame">
<div class="bs__map" aria-hidden="true">
<i class="bs__road bs__road--h"></i>
<i class="bs__road bs__road--v"></i>
<i class="bs__road bs__road--h2"></i>
<span class="bs__pin"></span>
<span class="bs__pin bs__pin--b"></span>
</div>
<section class="bs__sheet" id="bs-sheet" tabindex="0" aria-label="주변 카페 목록 시트">
<span class="bs__grab" aria-hidden="true"></span>
<header class="bs__head">
<strong>주변 카페</strong>
<span class="bs__steps">
<button class="bs__step" type="button" id="bs-down" aria-label="한 단계 내리기">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
</button>
<button class="bs__step" type="button" id="bs-up" aria-label="한 단계 올리기">
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg>
</button>
</span>
</header>
<ul class="bs__list">
<li><span class="bs__cafe" style="background:#2f6df6"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg></span><div><strong>모찌 카페</strong><span>도보 3분 · 4.8</span></div></li>
<li><span class="bs__cafe" style="background:#ff4d1f"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg></span><div><strong>주스 가게</strong><span>도보 5분 · 4.6</span></div></li>
<li><span class="bs__cafe" style="background:#4cd4a6"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg></span><div><strong>녹차 하우스</strong><span>도보 7분 · 4.9</span></div></li>
</ul>
</section>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.bs { display: flex; justify-content: center; }
.bs__frame {
position: relative;
width: 232px;
height: 168px;
border-radius: $r-card;
overflow: hidden;
background: #23202b;
}
.bs__map { position: absolute; inset: 0; }
.bs__road { position: absolute; background: rgba(255, 255, 255, .08); border-radius: $r-pill; }
.bs__road--h { left: -8px; right: 40px; top: 44px; height: 10px; }
.bs__road--v { top: -8px; bottom: 60px; left: 60px; width: 10px; }
.bs__road--h2 { left: 56px; right: -8px; top: 108px; height: 7px; }
.bs__pin {
position: absolute;
width: 12px; height: 12px;
border-radius: 50%;
background: $stage-orange;
box-shadow: 0 0 0 4px rgba(255, 77, 31, .22);
}
.bs__pin { top: 24px; left: 104px; }
.bs__pin--b { top: 84px; left: 148px; background: $subject-blue; box-shadow: 0 0 0 4px rgba(47, 109, 246, .22); }
.bs__sheet {
position: absolute;
left: 0; right: 0; bottom: 0;
box-sizing: border-box;
height: 150px;
background: $subject-cream;
border-radius: $r-card $r-card 0 0;
box-shadow: 0 -8px 20px rgba(0, 0, 0, .35);
transform: translateY(106px);
transition: transform $duration $easing;
touch-action: none;
cursor: grab;
}
.bs__sheet:active { cursor: grabbing; }
.bs__sheet:focus-visible { outline: 3px solid $color; outline-offset: -4px; }
.bs__grab {
display: block;
width: 36px; height: 4px;
margin: $sp-2 auto 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .22);
}
.bs__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-2 $sp-3 0;
}
.bs__head strong { font-size: 13px; line-height: 16px; font-weight: 800; color: $subject-ink; }
.bs__steps { display: flex; gap: $sp-1; }
.bs__step {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px;
border: 0;
background: rgba(23, 20, 26, .08);
border-radius: 50%;
color: $subject-ink;
cursor: pointer;
transition: background $dur-quick $easing;
}
.bs__step:hover { background: rgba(23, 20, 26, .18); }
.bs__step:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.bs__list { margin: $sp-2 0 0; padding: 0 $sp-2; list-style: none; }
.bs__list li {
display: flex;
align-items: center;
gap: $sp-2;
height: 30px;
padding: 0 $sp-2;
border-radius: $r-sm;
}
.bs__list li:nth-child(2) { background: rgba(47, 109, 246, .1); }
.bs__cafe {
display: inline-flex; align-items: center; justify-content: center;
width: 22px; height: 22px;
border-radius: 50%;
color: #fff;
font-size: 10px; line-height: 22px; font-weight: 800;
}
.bs__list div { display: flex; flex-direction: column; }
.bs__list strong { font-size: 11px; line-height: 13px; font-weight: 700; color: $subject-ink; }
.bs__list span { font-size: 9px; line-height: 11px; font-weight: 600; color: rgba(23, 20, 26, .72); }
// ── 자동 루프: peek → half → full → 아래로 던져 닫힘(peek). 끝=처음이라 이어진다 ──
.bs.is-demo .bs__sheet { animation: bs-snap $dur-loop $easing infinite; }
@keyframes bs-snap {
0%, 10% { transform: translateY(106px); }
22%, 38% { transform: translateY(54px); }
50%, 66% { transform: translateY(0); }
74%, 100% { transform: translateY(106px); }
}
.bs.is-demo .bs__list li:nth-child(2),
.bs.is-demo .bs__list li:nth-child(3) { animation: bs-reveal $dur-loop steps(1, end) infinite; }
@keyframes bs-reveal {
0%, 49% { opacity: 1; }
50%, 73% { opacity: 0; }
74%, 100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__sheet,
.bs.is-demo .bs__list li { animation: none !important; }
.bs.is-demo .bs__sheet { transform: translateY(54px); }
.bs__sheet { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.bs {
display: flex;
justify-content: center;
}
.bs__frame {
position: relative;
width: 232px;
height: 168px;
border-radius: 16px;
overflow: hidden;
background: #23202b;
}
.bs__map {
position: absolute;
inset: 0;
}
.bs__road {
position: absolute;
background: rgba(255, 255, 255, 0.08);
border-radius: 999px;
}
.bs__road--h {
left: -8px;
right: 40px;
top: 44px;
height: 10px;
}
.bs__road--v {
top: -8px;
bottom: 60px;
left: 60px;
width: 10px;
}
.bs__road--h2 {
left: 56px;
right: -8px;
top: 108px;
height: 7px;
}
.bs__pin {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
background: #ff4d1f;
box-shadow: 0 0 0 4px rgba(255, 77, 31, 0.22);
}
.bs__pin {
top: 24px;
left: 104px;
}
.bs__pin--b {
top: 84px;
left: 148px;
background: #2f6df6;
box-shadow: 0 0 0 4px rgba(47, 109, 246, 0.22);
}
.bs__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
height: 150px;
background: #fff7e6;
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.35);
transform: translateY(106px);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
touch-action: none;
cursor: grab;
}
.bs__sheet:active {
cursor: grabbing;
}
.bs__sheet:focus-visible {
outline: 3px solid #2f6df6;
outline-offset: -4px;
}
.bs__grab {
display: block;
width: 36px;
height: 4px;
margin: 8px auto 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
}
.bs__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px 0;
}
.bs__head strong {
font-size: 13px;
line-height: 16px;
font-weight: 800;
color: #17141a;
}
.bs__steps {
display: flex;
gap: 4px;
}
.bs__step {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 0;
background: rgba(23, 20, 26, 0.08);
border-radius: 50%;
color: #17141a;
cursor: pointer;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bs__step:hover {
background: rgba(23, 20, 26, 0.18);
}
.bs__step:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.bs__list {
margin: 8px 0 0;
padding: 0 8px;
list-style: none;
}
.bs__list li {
display: flex;
align-items: center;
gap: 8px;
height: 30px;
padding: 0 8px;
border-radius: 8px;
}
.bs__list li:nth-child(2) {
background: rgba(47, 109, 246, 0.1);
}
.bs__cafe {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: #fff;
font-size: 10px;
line-height: 22px;
font-weight: 800;
}
.bs__list div {
display: flex;
flex-direction: column;
}
.bs__list strong {
font-size: 11px;
line-height: 13px;
font-weight: 700;
color: #17141a;
}
.bs__list span {
font-size: 9px;
line-height: 11px;
font-weight: 600;
color: rgba(23, 20, 26, 0.72);
}
.bs.is-demo .bs__sheet {
animation: bs-snap 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bs-snap {
0%, 10% {
transform: translateY(106px);
}
22%, 38% {
transform: translateY(54px);
}
50%, 66% {
transform: translateY(0);
}
74%, 100% {
transform: translateY(106px);
}
}
.bs.is-demo .bs__list li:nth-child(2),
.bs.is-demo .bs__list li:nth-child(3) {
animation: bs-reveal 2s steps(1, end) infinite;
}
@keyframes bs-reveal {
0%, 49% {
opacity: 1;
}
50%, 73% {
opacity: 0;
}
74%, 100% {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.bs.is-demo .bs__sheet,
.bs.is-demo .bs__list li {
animation: none !important;
}
.bs.is-demo .bs__sheet {
transform: translateY(54px);
}
.bs__sheet {
transition: none;
}
}// 실제 동작: 시트가 손가락을 따라 오르내리다 놓으면 가까운 단계로 스냅, 아래로 던지면 닫힌다.
var root = document.getElementById('bs-root');
var frame = document.getElementById('bs-frame');
var sheet = document.getElementById('bs-sheet');
var H = 150; // 시트 높이
var SNAPS = [H - 44, H - 96, 0]; // peek, half, full (translateY 값)
var at = 0, dragging = false, startY = 0, lastY = 0, t0 = 0, v0 = 0, startPos = 0;
function paint(ty) { sheet.style.transform = 'translateY(' + ty + 'px)'; }
function go(i) {
at = Math.max(0, Math.min(SNAPS.length - 1, i));
paint(SNAPS[at]);
}
sheet.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragging = true;
startY = lastY = e.clientY; t0 = Date.now(); v0 = 0; startPos = SNAPS[at];
sheet.setPointerCapture(e.pointerId);
sheet.style.transition = 'none';
// e.preventDefault(); — 버튼 클릭을 막지 않게 놔둔다
});
sheet.addEventListener('pointermove', function (e) {
if (!dragging) return;
var now = Date.now();
if (now - t0 > 0) { v0 = (e.clientY - lastY) / Math.max(1, now - t0); lastY = e.clientY; t0 = now; }
paint(Math.max(0, startPos + (e.clientY - startY))); // 손가락을 따라 실시간으로
});
function drop() {
if (!dragging) return;
dragging = false;
sheet.style.transition = '';
var cur2 = startPos + (lastY - startY);
if (v0 > .5) { go(0); return; } // 아래로 세게 던짐 → 닫힘
if (v0 < -.5) { go(SNAPS.length - 1); return; } // 위로 세게 덗침 → 전체
var best = 0, dist = 1e9;
SNAPS.forEach(function (s, i) {
var d = Math.abs(cur2 - s);
if (d < dist) { dist = d; best = i; }
});
go(best);
}
sheet.addEventListener('pointerup', drop);
sheet.addEventListener('pointercancel', drop);
document.getElementById('bs-up').addEventListener('click', function () { root.classList.remove('is-demo'); go(at + 1); });
document.getElementById('bs-down').addEventListener('click', function () { root.classList.remove('is-demo'); go(at - 1); });
sheet.addEventListener('keydown', function (e) {
if (e.key === 'ArrowUp') { root.classList.remove('is-demo'); go(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowDown' || e.key === 'Escape') { root.classList.remove('is-demo'); go(at - 1); e.preventDefault(); }
});
go(0);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomSheet.module.scss";
export type BottomSheetProps = {
title?: string;
rows?: { name: string; meta: string; hue: string }[];
duration?: number;
easing?: string;
color?: string;
};
type PEvt = { clientX: number; clientY: number; pointerId: number; preventDefault(): void; currentTarget: { setPointerCapture(id: number): void } };
type KeyEvt = { key: string; preventDefault(): void };
type Box<T> = { current: T };
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const H = 150;
const SNAPS = [H - 44, H - 96, 0]; // peek · half · full
const IconUp = () => (
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg>
);
const IconDown = () => (
<svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m208 96l-80 80l-80-80Z" opacity=".2"/><path d="M215.39 92.94A8 8 0 0 0 208 88H48a8 8 0 0 0-5.66 13.66l80 80a8 8 0 0 0 11.32 0l80-80a8 8 0 0 0 1.73-8.72M128 164.69L67.31 104h121.38Z"/></g></svg>
);
const IconCoffee = () => (
<svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88v48a88 88 0 0 1-51.3 80H83.3A88 88 0 0 1 32 136V88Z" opacity=".2"/><path d="M224 120v8a40 40 0 0 1-37.51 39.91a96.6 96.6 0 0 1-27 40.09H208a8 8 0 0 1 0 16H32a8 8 0 0 1 0-16h24.54A96.3 96.3 0 0 1 24 136V88a8 8 0 0 1 8-8h176a40 40 0 0 1 40 40m-48-24H40v40a80.27 80.27 0 0 0 45.12 72h69.76A80.27 80.27 0 0 0 200 136Z"/></g></svg>
);
export default function BottomSheet({
title = "Nearby cafes",
rows = [
{ name: "Mochi Cafe", meta: "3-min walk · 4.8", hue: "#2f6df6" },
{ name: "Juice Stand", meta: "5-min walk · 4.6", hue: "#ff4d1f" },
{ name: "Tea House", meta: "7-min walk · 4.9", hue: "#4cd4a6" },
],
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: BottomSheetProps) {
const [at, setAt] = useState(0);
const [ty, setTy] = useState(SNAPS[0]);
const [dragging, setDragging] = useState(false);
const drag = useRef({ startY: 0, lastY: 0, t0: 0, v: 0, startPos: 0 }) as Box<{ startY: number; lastY: number; t0: number; v: number; startPos: number }>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function go(i: number) {
const n = Math.max(0, Math.min(SNAPS.length - 1, i));
setAt(n);
setTy(SNAPS[n]);
}
function onDown(e: PEvt) {
drag.current = { startY: e.clientY, lastY: e.clientY, t0: Date.now(), v: 0, startPos: SNAPS[at] };
setDragging(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
function onMove(e: PEvt) {
if (!dragging) return;
const now = Date.now();
if (now - drag.current.t0 > 0) {
drag.current.v = (e.clientY - drag.current.lastY) / Math.max(1, now - drag.current.t0);
drag.current.lastY = e.clientY;
drag.current.t0 = now;
}
setTy(Math.max(0, drag.current.startPos + (e.clientY - drag.current.startY)));
}
function onUp() {
if (!dragging) return;
setDragging(false);
const cur = drag.current.startPos + (drag.current.lastY - drag.current.startY);
if (drag.current.v > 0.5) { go(0); return; }
if (drag.current.v < -0.5) { go(SNAPS.length - 1); return; }
let best = 0, dist = 1e9;
SNAPS.forEach((s, i) => {
const d = Math.abs(cur - s);
if (d < dist) { dist = d; best = i; }
});
go(best);
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowUp") { go(at + 1); e.preventDefault(); }
else if (e.key === "ArrowDown" || e.key === "Escape") { go(at - 1); e.preventDefault(); }
}
useEffect(() => {
const el = document.documentElement;
function wheel(e: WheelEvent) { if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) e.preventDefault(); }
el.addEventListener("wheel", wheel, { passive: false });
return () => el.removeEventListener("wheel", wheel);
}, []);
return (
<div className={styles.bs} style={vars}>
<div className={styles.frame}>
<div className={styles.map} aria-hidden="true">
<i className={styles.roadH} /><i className={styles.roadV} /><i className={styles.roadH2} />
<span className={styles.pin} /><span className={`${styles.pin} ${styles.pinB}`} />
</div>
<section
className={dragging ? styles.sheetRaw : styles.sheet}
style={{ transform: `translateY(${ty}px)` }}
tabIndex={0}
aria-label={`${title} sheet`}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={onKey}
>
<span className={styles.grab} aria-hidden="true" />
<header className={styles.head}>
<strong>{title}</strong>
<span className={styles.steps}>
<button type="button" className={styles.step} aria-label="Down one step" onClick={(): void => go(at - 1)}>
<IconDown />
</button>
<button type="button" className={styles.step} aria-label="Up one step" onClick={(): void => go(at + 1)}>
<IconUp />
</button>
</span>
</header>
<ul className={styles.list}>
{rows.map((r, i) => (
<li key={r.name} className={i === 1 ? styles.rowOn : undefined}>
<span className={styles.cafe} style={{ background: r.hue }} aria-hidden="true"><IconCoffee /></span>
<div><strong>{r.name}</strong><span>{r.meta}</span></div>
</li>
))}
</ul>
</section>
</div>
</div>
);
}08 끌어올리는 바텀시트 — 측정 누적면적 22.508% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI
이 데모가 속한 글
모바일 터치 제스처 9종 — 손가락 하나로 돌아가는 UI모바일 터치 제스처는 손가락이 화면에 닿아 움직인 거리와 속도를 읽어 화면을 바꾸는 조작 방식입니다. 아홉 개 중 둘은 손을 떼는 순간에야 결과가 갈리고, 하나는 끝까지 밀지 않으면 아무 일도 일어나지 않습니다. 이 글의 데모 전부 보기


