04Bottom sheet snap 3 mức
Mỗi lần chạm tay cầm, translateY snap về một trong ba mốc cố định: peek, half hoặc full.
Dùng choDanh sách địa điểm trong app bản đồ, mở rộng theo từng giai đoạn
3단 스냅peek·half·full손잡이 클릭

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="sheet4">Mở danh sách</button>
</div>
<div class="overlay"></div>
<div class="sheet" id="sheet4" role="dialog" aria-modal="true" aria-labelledby="t4" data-snap="half">
<button class="sheet__handle" type="button" aria-label="Chuyển cỡ sheet (peek, half, full)"></button>
<h2 id="t4">12 địa điểm gần đây</h2>
<p>Mỗi lần chạm vào tay cầm sẽ chuyển lần lượt peek → half → full.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 스냅마다 딱 멈추는 시간
$easing: $ease-out; // 관성 없이 멈춘다(스프링 아님)
$color: $subject-ink; // 손잡이·강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-paper);
.stage { position: relative; }
.page { display: grid; place-items: center; }
.trigger {
border: 0; border-radius: $r-pill;
padding: $sp-3 $sp-6;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: $stage-paper; background: $color;
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .45);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing;
}
// 세 스냅 값은 시트 자기 높이 기준 %(transform 만 사용)
.sheet {
position: fixed; left: 50%; bottom: 0; top: auto;
transform: translate(-50%, 100%);
width: min(94vw, 300px);
box-sizing: border-box;
padding: $sp-2 $sp-4 $sp-6;
border-radius: $r-card $r-card 0 0;
background: $subject-cream; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing;
h2 { margin: $sp-2 0 $sp-1; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .7); }
}
.sheet__handle {
display: block;
width: 36px; height: $sp-1; margin: 0 auto;
border: 0; border-radius: $r-pill; background: rgba($stage-ink, .28);
cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; pointer-events: auto; }
.stage.is-open .sheet { pointer-events: auto; }
.stage.is-open .sheet[data-snap="peek"] { transform: translate(-50%, 78%); }
.stage.is-open .sheet[data-snap="half"] { transform: translate(-50%, 40%); }
.stage.is-open .sheet[data-snap="full"] { transform: translate(-50%, 0); }
// 데모 루프: 닫힘 → peek → half → full → peek 순으로 2초 안에 한 바퀴
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .sheet { animation: sheet-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; }
10%, 90% { opacity: 1; }
95% { opacity: 0; }
}
@keyframes sheet-loop {
0% { transform: translate(-50%, 100%); }
10% { transform: translate(-50%, 78%); }
40% { transform: translate(-50%, 40%); }
70% { transform: translate(-50%, 0); }
90% { transform: translate(-50%, 78%); }
100% { transform: translate(-50%, 100%); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .sheet { animation: none; }
.overlay, .sheet { transition-duration: 1ms; }
}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;
}
.page {
display: grid;
place-items: center;
}
.trigger {
border: 0;
border-radius: 999px;
padding: 12px 24px;
font-weight: 600;
font-size: clamp(13px, 4vmin, 16px);
line-height: 1;
font-family: inherit;
color: #ffffff;
background: #17141a;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out;
}
.sheet {
position: fixed;
left: 50%;
bottom: 0;
top: auto;
transform: translate(-50%, 100%);
width: min(94vw, 300px);
box-sizing: border-box;
padding: 8px 16px 24px;
border-radius: 16px 16px 0 0;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 300ms ease-out;
}
.sheet h2 {
margin: 8px 0 4px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.sheet p {
margin: 0;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.7);
}
.sheet__handle {
display: block;
width: 36px;
height: 4px;
margin: 0 auto;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.28);
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .sheet {
pointer-events: auto;
}
.stage.is-open .sheet[data-snap=peek] {
transform: translate(-50%, 78%);
}
.stage.is-open .sheet[data-snap=half] {
transform: translate(-50%, 40%);
}
.stage.is-open .sheet[data-snap=full] {
transform: translate(-50%, 0);
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .sheet {
animation: sheet-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
}
10%, 90% {
opacity: 1;
}
95% {
opacity: 0;
}
}
@keyframes sheet-loop {
0% {
transform: translate(-50%, 100%);
}
10% {
transform: translate(-50%, 78%);
}
40% {
transform: translate(-50%, 40%);
}
70% {
transform: translate(-50%, 0);
}
90% {
transform: translate(-50%, 78%);
}
100% {
transform: translate(-50%, 100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .sheet {
animation: none;
}
.overlay, .sheet {
transition-duration: 1ms;
}
}// 손잡이를 누를 때마다 data-snap 을 세 값 사이로 순환시킨다. CSS 는 그 값에 맞는 translateY 로만 반응.
(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var sheet = document.querySelector('.sheet');
var handle = document.querySelector('.sheet__handle');
var steps = ['peek', 'half', 'full'];
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
handle.focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open'); sheet.dataset.snap = 'half';
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
handle.addEventListener('click', function () {
var i = steps.indexOf(sheet.dataset.snap);
sheet.dataset.snap = steps[(i + 1) % steps.length];
});
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
document.addEventListener('keydown', function (e) {
if (stage.classList.contains('is-open') && e.key === 'Escape') shut();
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BottomSheetSnap.module.scss";
export type SnapStep = "closed" | "peek" | "half" | "full";
export type BottomSheetSnapProps = {
duration?: number;
easing?: string;
color?: string;
};
const STEPS: SnapStep[] = ["peek", "half", "full"];
export default function BottomSheetSnap({
duration = 300,
easing = "ease-out",
color = "#17141a",
}: BottomSheetSnapProps) {
const [snap, setSnap] = useState<SnapStep>("closed");
const open = snap !== "closed";
function cycle() {
const i = STEPS.indexOf(snap === "closed" ? "peek" : snap);
setSnap(STEPS[(i + 1) % STEPS.length]);
}
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<button className={styles.trigger} type="button" onClick={() => setSnap("half")}>목록 열기</button>
<div className={styles.overlay} data-open={open} onClick={() => setSnap("closed")} />
<div className={styles.sheet} data-snap={snap} role="dialog" aria-modal="true" aria-label="주변 장소 12곳">
<button
className={styles.handle}
type="button"
aria-label="시트 크기 전환(peek·half·full)"
onClick={cycle}
/>
<h2>주변 장소 12곳</h2>
<p>손잡이를 누를 때마다 peek → half → full 순으로 바뀐다.</p>
</div>
</div>
);
}04 Bottom sheet snap 3 mức — Diện tích đo được 97.949% · khung hình chuyển động 23/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 hiệu ứng CSS Sidebar Animation - JS tối thiểu
Bài viết chứa demo này
9 hiệu ứng CSS Sidebar Animation - JS tối thiểuCSS sidebar animation là cách một drawer đóng mở bằng transform và opacity, còn JavaScript chỉ lo phần bắt buộc: bật trạ Xem tất cả demo trong bài này


