03Overlay mờ dần
Panel đứng yên tại chỗ, chỉ hiện ra bằng opacity và scale, còn overlay phía sau mờ đi nhờ backdrop-filter.
Dùng choPanel lọc, tùy chọn mở ra mà không di chuyển qua màn hình
opacity+scalebackdrop-filter위치 고정

<main class="stage is-demo">
<div class="page" id="page">
<button class="trigger" type="button" aria-haspopup="dialog" aria-controls="drawer3">Mở bộ lọc</button>
</div>
<div class="overlay"></div>
<div class="drawer" id="drawer3" role="dialog" aria-modal="true" aria-labelledby="t3">
<h2 id="t3">Bộ lọc</h2>
<p>Chọn giá, vận chuyển và màu sắc ở đây.</p>
<button class="drawer__close" type="button">Đóng</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base; // 300ms — 자리에서 나타나는 시간
$easing: $ease-out;
$color: $subject-cream; // 트리거 강조색
// ── 데모 무대 ───────────────────────────────────────────
@include stage($stage-orange);
.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: $subject-orange-deep; background: $color; // AA: 크림 면 위 주황 글자 5.04:1
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
// 프로스트 유리 느낌: opacity + backdrop-filter 로 흐려진다(01 의 단색 어둡기와 다르다)
.overlay {
position: fixed; inset: 0;
background: rgba($stage-ink, .28);
backdrop-filter: blur(0px);
opacity: 0; pointer-events: none;
transition: opacity $duration $easing, backdrop-filter $duration $easing;
}
// 패널은 왼쪽에서 움직이지 않는다 — opacity·transform:scale 만으로 나타난다
.drawer {
position: fixed; left: 0; top: 0; bottom: 0; right: auto;
transform: scale(.96);
transform-origin: left center; // 규칙8: 왼쪽 붙은 패널 좌변 기준
opacity: 0;
width: min(76vw, 240px);
box-sizing: border-box;
display: flex; flex-direction: column; gap: $sp-2;
padding: $sp-6 $sp-4;
background: $stage-paper; color: $stage-ink;
box-shadow: $shadow-raised;
pointer-events: none;
transition: transform $duration $easing, opacity $duration $easing;
h2 { margin: 0 0 $sp-2; font-size: clamp(13px, 4.2vmin, 17px); }
p { margin: 0 0 $sp-4; font-size: clamp(10px, 3.2vmin, 13px); color: rgba($stage-ink, .7); }
}
.drawer__close {
margin-top: auto;
border: 0; border-radius: $r-control;
background: $stage-orange; color: $stage-paper;
padding: $sp-2 $sp-4;
font-weight: 600; font-size: 13px; line-height: 1; font-family: inherit; cursor: pointer;
}
.stage.is-open .overlay { opacity: 1; backdrop-filter: blur(6px); pointer-events: auto; }
.stage.is-open .drawer { opacity: 1; transform: scale(1); pointer-events: auto; }
.stage.is-demo .overlay { animation: overlay-loop $dur-loop ease-in-out infinite; }
.stage.is-demo .drawer { animation: drawer-loop $dur-loop $easing infinite; }
@keyframes overlay-loop {
0%, 100% { opacity: 0; backdrop-filter: blur(0px); }
15%, 70% { opacity: 1; backdrop-filter: blur(6px); }
85% { opacity: 0; backdrop-filter: blur(0px); }
}
@keyframes drawer-loop {
0%, 100% { opacity: 0; transform: scale(.96); }
15%, 70% { opacity: 1; transform: scale(1); }
85% { opacity: 0; transform: scale(.96); }
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer { animation: none; }
.overlay, .drawer { 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: #ff4d1f;
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: #c93000;
background: #fff7e6;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(23, 20, 26, 0.28);
backdrop-filter: blur(0px);
opacity: 0;
pointer-events: none;
transition: opacity 300ms ease-out, backdrop-filter 300ms ease-out;
}
.drawer {
position: fixed;
left: 0;
top: 0;
bottom: 0;
right: auto;
transform: scale(0.96);
transform-origin: left center;
opacity: 0;
width: min(76vw, 240px);
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
padding: 24px 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
pointer-events: none;
transition: transform 300ms ease-out, opacity 300ms ease-out;
}
.drawer h2 {
margin: 0 0 8px;
font-size: clamp(13px, 4.2vmin, 17px);
}
.drawer p {
margin: 0 0 16px;
font-size: clamp(10px, 3.2vmin, 13px);
color: rgba(23, 20, 26, 0.7);
}
.drawer__close {
margin-top: auto;
border: 0;
border-radius: 12px;
background: #ff4d1f;
color: #ffffff;
padding: 8px 16px;
font-weight: 600;
font-size: 13px;
line-height: 1;
font-family: inherit;
cursor: pointer;
}
.stage.is-open .overlay {
opacity: 1;
backdrop-filter: blur(6px);
pointer-events: auto;
}
.stage.is-open .drawer {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
.stage.is-demo .overlay {
animation: overlay-loop 2s ease-in-out infinite;
}
.stage.is-demo .drawer {
animation: drawer-loop 2s ease-out infinite;
}
@keyframes overlay-loop {
0%, 100% {
opacity: 0;
backdrop-filter: blur(0px);
}
15%, 70% {
opacity: 1;
backdrop-filter: blur(6px);
}
85% {
opacity: 0;
backdrop-filter: blur(0px);
}
}
@keyframes drawer-loop {
0%, 100% {
opacity: 0;
transform: scale(0.96);
}
15%, 70% {
opacity: 1;
transform: scale(1);
}
85% {
opacity: 0;
transform: scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .overlay, .stage.is-demo .drawer {
animation: none;
}
.overlay, .drawer {
transition-duration: 1ms;
}
}// 패널은 이동하지 않고 자리에서 opacity·scale 로만 나타난다. 열림 시 #page 를 inert 로 막는다.
(function () {
var stage = document.querySelector('.stage');
var page = document.getElementById('page');
var trigger = document.querySelector('.trigger');
var overlay = document.querySelector('.overlay');
var panel = document.querySelector('.drawer');
var closeBtn = document.querySelector('.drawer__close');
function focusables() { return panel.querySelectorAll('a, button'); }
function open() {
stage.classList.remove('is-demo'); stage.classList.add('is-open');
page.setAttribute('inert', ''); page.setAttribute('aria-hidden', 'true');
focusables()[0].focus({ preventScroll: true });
}
function shut() {
stage.classList.remove('is-open');
page.removeAttribute('inert'); page.removeAttribute('aria-hidden');
trigger.focus({ preventScroll: true });
}
trigger.addEventListener('click', open);
overlay.addEventListener('click', shut);
closeBtn.addEventListener('click', shut);
document.addEventListener('keydown', function (e) {
if (!stage.classList.contains('is-open')) return;
if (e.key === 'Escape') { shut(); return; }
if (e.key !== 'Tab') return;
var f = focusables(), first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
});
})();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OverlayFadeDrawer.module.scss";
export type OverlayFadeDrawerProps = {
duration?: number;
easing?: string;
color?: string;
};
export default function OverlayFadeDrawer({
duration = 300,
easing = "ease-out",
color = "#fff7e6",
}: OverlayFadeDrawerProps) {
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
if (!open) { triggerRef.current?.focus({ preventScroll: true }); return; }
const panel = panelRef.current;
if (!panel) return;
const focusables = () => Array.from(panel.querySelectorAll<HTMLElement>("a, button"));
focusables()[0]?.focus({ preventScroll: true });
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { setOpen(false); return; }
if (e.key !== "Tab") return;
const f = focusables();
const first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { last.focus({ preventScroll: true }); e.preventDefault(); }
else if (!e.shiftKey && document.activeElement === last) { first.focus({ preventScroll: true }); e.preventDefault(); }
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
const vars: CSSProperties = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.stage} style={vars}>
<div className={styles.page} inert={open || undefined} aria-hidden={open}>
<button ref={triggerRef} className={styles.trigger} type="button" onClick={() => setOpen(true)}>
필터 열기
</button>
</div>
<div className={styles.overlay} data-open={open} onClick={() => setOpen(false)} />
<div ref={panelRef} className={styles.drawer} data-open={open} role="dialog" aria-modal="true" aria-label="필터">
<h2>필터</h2>
<p>가격·배송·색상 옵션을 여기서 고른다.</p>
<button className={styles.close} type="button" onClick={() => setOpen(false)}>닫기</button>
</div>
</div>
);
}03 Overlay mờ dần — Diện tích đo được 100.0% · khung hình chuyển động 8/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


