08Drag-up bottom sheet
The sheet follows the finger in real time, snaps to the nearest step on release, closes when flung downward, and steps the same way via buttons and Escape.
Best forMaps and mini players — panels that live on one screen at several heights
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="Nearby cafes sheet">
<span class="bs__grab" aria-hidden="true"></span>
<header class="bs__head">
<strong>Nearby cafes</strong>
<span class="bs__steps">
<button class="bs__step" type="button" id="bs-down" aria-label="Down one step">
<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="Up one step">
<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>Mochi Cafe</strong><span>3-min walk · 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>Juice Stand</strong><span>5-min walk · 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>Tea House</strong><span>7-min walk · 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 Drag-up bottom sheet — Measured area 22.508% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · 9 Mobile Touch Gestures That Follow Your Finger
The post this demo lives in
9 Mobile Touch Gestures That Follow Your FingerMobile touch gestures turn the distance and speed of a finger on glass into a change on screen. Nine of them are below, See all demos in this post


