9 hiệu ứng css hamburger menu animation không JS
Css hamburger menu animation là chuyển động của nút ba vạch khi menu mở và đóng, dựng bằng transition và transform của CSS thay vì một thư viện JS.
01 hamburger-menu-bars-to-x
02 hamburger-menu-spin-to-x
03 hamburger-menu-arrow-morph
04 hamburger-menu-slide-out-middle
05 hamburger-menu-fullscreen-overlay
06 hamburger-menu-popover-menu-no-js
07 hamburger-menu-stagger-links
08 hamburger-menu-circle-reveal-open
09 hamburger-menu-backdrop-blur-dim
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 3 vạch chuyển thành X
- 02 Xoay thành X
- 03 Biến thành mũi tên
- 04 Vạch giữa trượt ra
- 05 Menu overlay toàn màn hình
- 06 Menu không cần JS với Popover API
- 07 Link xuất hiện lần lượt
- 08 Lan tỏa hình tròn từ icon
- 09 Làm mờ và tối nền
Thứ tự chín mục đi từ trong ra ngoài. Từ 01 đến 04 chỉ đổi hình dạng của icon ba vạch — X thẳng, X xoay, mũi tên, vạch giữa trượt ra — trong khi khối menu bên dưới vẫn hiện ra theo đúng kiểu opacity và transform quen thuộc. Mục 05 và 06 đổi luôn cách cả khối menu xuất hiện, một cái bằng panel toàn màn hình, một cái bằng thuộc tính popover có sẵn của trình duyệt. 07 đến 09 giữ nguyên cách menu hiện ra cơ bản nhưng thêm một lớp hiệu ứng phụ lên trên — link hiện lần lượt, một vòng tròn loang ra, nền phía sau mờ và tối đi. Ở lưới phía trên, bạn xem từng ô tự lặp lại mỗi hai giây bên trong khung iframe; những ô đó không phải nút bấm, và bản thật cần mở riêng file mới nhấn được.
013 vạch chuyển thành X
Bạn thấy gì
Vạch trên và vạch dưới xoay 45 độ quanh tâm rồi chồng lên nhau thành chữ X trong 300ms, còn vạch giữa chỉ scaleX về 0 và mờ dần đi chứ không xoay.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<nav class="menu" aria-hidden="true">
<a class="menu__link" href="#">Trang chủ</a>
<a class="menu__link" href="#">Giới thiệu</a>
<a class="menu__link" href="#">Sản phẩm</a>
<a class="menu__link" href="#">Liên hệ</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 300ms; // 줄이 X로 겹치는 시간
$easing: $ease-spring;
$color: $subject-cream; // 줄·메뉴 글자 색
@include stage($stage-ink);
.stage { position: relative; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 2; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.menu {
position: absolute; top: clamp(52px, 16vmin, 64px); left: clamp(14px, 5vmin, 20px);
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left; // 규칙8: 버튼 모서리에서 펼침
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.menu__link { font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-ink; text-decoration: none; }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.stage.is-demo .menu { animation: menu-loop $dur-loop $easing infinite; }
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; transform: translateY(-8px) scale(.96); }
15% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
85% { opacity: 0; transform: translateY(-8px) scale(.96); }
100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
// ── 접근성: 움직임 줄이기 설정이면 즉시 상태만 남긴다 ────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu { animation: none; }
.bar, .menu { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 2;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #fff7e6;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: clamp(52px, 16vmin, 64px);
left: clamp(14px, 5vmin, 20px);
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px) scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.menu__link {
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .menu {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .menu {
animation: menu-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
15% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu {
animation: none;
}
.bar, .menu {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BarsToX.module.scss";
export type BarsToXProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 줄이 X로 겹치는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BarsToX({
links,
openLabel,
closeLabel,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: BarsToXProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label) => (
<a key={label} className={styles.menuLink} href="#">
{label}
</a>
))}
</nav>
</div>
);
}01 · 3 vạch chuyển thành X — Diện tích đo được 9.553% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Cả ba đều dùng chung easing $ease-spring nên khớp nhịp với nhau, và đây là kiểu chuyển động mặc định cho một nút hamburger ở header bình thường.
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
02Xoay thành X
Chuyện gì diễn ra trên màn hình
Cùng phép biến hình X như mục 01, nhưng cả nút .burger tự xoay thêm 180 độ trong lúc ba vạch morph, và easing đổi sang kiểu nảy nhẹ ở cuối nên icon khựng lại một nhịp trước khi dừng hẳn, thay vì dừng êm. 350ms dài hơn mục 01 năm mươi mili giây vì có thêm chuyển động xoay của cả khối cần thời gian để mắt theo kịp.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<nav class="menu" aria-hidden="true">
<a class="menu__link" href="#">Trang chủ</a>
<a class="menu__link" href="#">Giới thiệu</a>
<a class="menu__link" href="#">Sản phẩm</a>
<a class="menu__link" href="#">Liên hệ</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 350ms; // 아이콘이 도는 시간
$easing: $ease-pop; // 끝에서 살짝 튕긴다
$color: $subject-ink; // 줄·메뉴 글자 색
@include stage($stage-yellow);
.stage { position: relative; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 2; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
transform: rotate(0deg);
transition: transform $duration $easing;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.menu {
position: absolute; top: clamp(52px, 16vmin, 64px); left: clamp(14px, 5vmin, 20px);
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left; // 규칙8: 버튼 모서리에서 펼침
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.menu__link { font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-ink; text-decoration: none; }
.stage.is-open .burger { transform: rotate(180deg); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.stage.is-demo .burger { animation: burger-loop $dur-loop $easing infinite; }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.stage.is-demo .menu { animation: menu-loop $dur-loop $easing infinite; }
@keyframes burger-loop {
0% { transform: rotate(0deg); }
15% { transform: rotate(180deg); }
70% { transform: rotate(180deg); }
85% { transform: rotate(0deg); }
100% { transform: rotate(0deg); }
}
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; transform: translateY(-8px) scale(.96); }
15% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
85% { opacity: 0; transform: translateY(-8px) scale(.96); }
100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .burger, .stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu { animation: none; }
.burger, .bar, .menu { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 2;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transform: rotate(0deg);
transition: transform 350ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #17141a;
transition: transform 350ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 350ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: clamp(52px, 16vmin, 64px);
left: clamp(14px, 5vmin, 20px);
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px) scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 350ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 350ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.menu__link {
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
}
.stage.is-open .burger {
transform: rotate(180deg);
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .menu {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.stage.is-demo .burger {
animation: burger-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.stage.is-demo .menu {
animation: menu-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes burger-loop {
0% {
transform: rotate(0deg);
}
15% {
transform: rotate(180deg);
}
70% {
transform: rotate(180deg);
}
85% {
transform: rotate(0deg);
}
100% {
transform: rotate(0deg);
}
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
15% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .burger, .stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu {
animation: none;
}
.burger, .bar, .menu {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpinToX.module.scss";
export type SpinToXProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 아이콘이 도는 시간 (기본 350ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SpinToX({
links,
openLabel,
closeLabel,
duration = 350,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: SpinToXProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label) => (
<a key={label} className={styles.menuLink} href="#">
{label}
</a>
))}
</nav>
</div>
);
}02 · Xoay thành X — Diện tích đo được 13.016% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
.burger {
transform: rotate(0deg);
transition: transform $duration $easing;
}
.stage.is-open .burger { transform: rotate(180deg); }
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
03Biến thành mũi tên
Chuyển động thế nào
Ba vạch không gộp thành X mà co lại quanh cạnh phải: vạch trên xoay -35 độ, vạch dưới xoay 35 độ, vạch giữa scaleX còn 0.7, tạo hình một dấu ngoặc nhọn chỉ sang trái trong 250ms.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<nav class="menu" aria-hidden="true">
<a class="menu__link" href="#">Trang chủ</a>
<a class="menu__link" href="#">Giới thiệu</a>
<a class="menu__link" href="#">Sản phẩm</a>
<a class="menu__link" href="#">Liên hệ</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 250ms; // 화살표로 좁아지는 시간
$easing: $ease-spring;
$color: $subject-cream; // 줄·메뉴 글자 색
@include stage($stage-orange);
.stage { position: relative; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 2; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
// 오른쪽 끝을 축으로 회전시켜 왼쪽 끝이 모이면 "<" 모양이 된다
.bar {
position: absolute; right: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transform-origin: right center; // 규칙8: 오른쪽 끝축 막대 회전
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(50%, -50%) translateY(7px) rotate(0deg); }
.menu {
position: absolute; top: clamp(52px, 16vmin, 64px); left: clamp(14px, 5vmin, 20px);
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left; // 규칙8: 버튼 모서리에서 펼침
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.menu__link { font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-ink; text-decoration: none; }
.stage.is-open .bar1 { transform: translate(50%, -50%) translateY(-3px) rotate(-35deg); }
.stage.is-open .bar2 { transform: translate(50%, -50%) scaleX(.7); opacity: 1; }
.stage.is-open .bar3 { transform: translate(50%, -50%) translateY(3px) rotate(35deg); }
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.stage.is-demo .menu { animation: menu-loop $dur-loop $easing infinite; }
@keyframes bar1-loop {
0% { transform: translate(50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(50%, -50%) translateY(-3px) rotate(-35deg); }
70% { transform: translate(50%, -50%) translateY(-3px) rotate(-35deg); }
85% { transform: translate(50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(50%, -50%) scaleX(1); }
15% { transform: translate(50%, -50%) scaleX(.7); }
70% { transform: translate(50%, -50%) scaleX(.7); }
85% { transform: translate(50%, -50%) scaleX(1); }
100% { transform: translate(50%, -50%) scaleX(1); }
}
@keyframes bar3-loop {
0% { transform: translate(50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(50%, -50%) translateY(3px) rotate(35deg); }
70% { transform: translate(50%, -50%) translateY(3px) rotate(35deg); }
85% { transform: translate(50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; transform: translateY(-8px) scale(.96); }
15% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
85% { opacity: 0; transform: translateY(-8px) scale(.96); }
100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu { animation: none; }
.bar, .menu { 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;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 2;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
right: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #fff7e6;
transform-origin: right center;
transition: transform 250ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: clamp(52px, 16vmin, 64px);
left: clamp(14px, 5vmin, 20px);
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px) scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 250ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 250ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.menu__link {
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
}
.stage.is-open .bar1 {
transform: translate(50%, -50%) translateY(-3px) rotate(-35deg);
}
.stage.is-open .bar2 {
transform: translate(50%, -50%) scaleX(0.7);
opacity: 1;
}
.stage.is-open .bar3 {
transform: translate(50%, -50%) translateY(3px) rotate(35deg);
}
.stage.is-open .menu {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .menu {
animation: menu-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bar1-loop {
0% {
transform: translate(50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(50%, -50%) translateY(-3px) rotate(-35deg);
}
70% {
transform: translate(50%, -50%) translateY(-3px) rotate(-35deg);
}
85% {
transform: translate(50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(50%, -50%) scaleX(1);
}
15% {
transform: translate(50%, -50%) scaleX(0.7);
}
70% {
transform: translate(50%, -50%) scaleX(0.7);
}
85% {
transform: translate(50%, -50%) scaleX(1);
}
100% {
transform: translate(50%, -50%) scaleX(1);
}
}
@keyframes bar3-loop {
0% {
transform: translate(50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(50%, -50%) translateY(3px) rotate(35deg);
}
70% {
transform: translate(50%, -50%) translateY(3px) rotate(35deg);
}
85% {
transform: translate(50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
15% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu {
animation: none;
}
.bar, .menu {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ArrowMorph.module.scss";
export type ArrowMorphProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 화살표로 좁아지는 시간 (기본 250ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ArrowMorph({
links,
openLabel,
closeLabel,
duration = 250,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ArrowMorphProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label) => (
<a key={label} className={styles.menuLink} href="#">
{label}
</a>
))}
</nav>
</div>
);
}03 · Biến thành mũi tên — Diện tích đo được 13.206% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Vì transform-origin đặt ở right center nên các vạch xoay quanh đúng điểm chụm lại chứ không lệch tâm, hợp cho nút vừa mở menu vừa mang nghĩa quay lại.
.bar {
transform-origin: right center;
}
.stage.is-open .bar1 {
transform: translate(50%, -50%) translateY(-3px) rotate(-35deg);
}
.stage.is-open .bar2 { transform: translate(50%, -50%) scaleX(.7); opacity: 1; }
.stage.is-open .bar3 {
transform: translate(50%, -50%) translateY(3px) rotate(35deg);
}
04Vạch giữa trượt ra
Bạn thấy gì
Vạch giữa translateX 140% ra khỏi khung và biến mất trước, rồi mới đến lượt vạch trên dưới xoay chồng thành X — hai bước đó lệch nhau bằng cách chia phần trăm keyframe khác nhau trong cùng 280ms chứ không phải hai animation riêng.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<nav class="menu" aria-hidden="true">
<a class="menu__link" href="#">Trang chủ</a>
<a class="menu__link" href="#">Giới thiệu</a>
<a class="menu__link" href="#">Sản phẩm</a>
<a class="menu__link" href="#">Liên hệ</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 280ms; // 가운데 줄이 빠지는 시간
$easing: $ease-spring;
$color: $subject-blue; // 줄·메뉴 글자 색 (paper 무대라 blue)
@include stage($stage-paper);
.stage { position: relative; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 2; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.menu {
position: absolute; top: clamp(52px, 16vmin, 64px); left: clamp(14px, 5vmin, 20px);
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-ink; box-shadow: $shadow-raised;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left; // 규칙8: 버튼 모서리에서 펼침
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing;
}
.menu__link { font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-paper; text-decoration: none; }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.stage.is-demo .menu { animation: menu-loop $dur-loop $easing infinite; }
// 가운데 줄이 먼저 빠지고, 그다음 위·아래 줄이 X로 겹친다. 닫힐 땐 반대 순서.
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
78% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
8% { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
78% { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
85% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
100% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
78% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; transform: translateY(-8px) scale(.96); }
15% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
85% { opacity: 0; transform: translateY(-8px) scale(.96); }
100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu { animation: none; }
.bar, .menu { 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;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 2;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #2f6df6;
transition: transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) translateX(0);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: clamp(52px, 16vmin, 64px);
left: clamp(14px, 5vmin, 20px);
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px) scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 280ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 280ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.menu__link {
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #ffffff;
text-decoration: none;
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) translateX(140%);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .menu {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .menu {
animation: menu-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
78% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) translateX(0);
opacity: 1;
}
8% {
transform: translate(-50%, -50%) translateX(140%);
opacity: 0;
}
78% {
transform: translate(-50%, -50%) translateX(140%);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) translateX(0);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) translateX(0);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
78% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
15% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu {
animation: none;
}
.bar, .menu {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SlideOutMiddle.module.scss";
export type SlideOutMiddleProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 가운데 줄이 빠지는 시간 (기본 280ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function SlideOutMiddle({
links,
openLabel,
closeLabel,
duration = 280,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: SlideOutMiddleProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label) => (
<a key={label} className={styles.menuLink} href="#">
{label}
</a>
))}
</nav>
</div>
);
}04 · Vạch giữa trượt ra — Diện tích đo được 13.229% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Đóng lại thì thứ tự đảo ngược, X tách ra trước rồi vạch giữa mới trượt về.
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
8% { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
78% { transform: translate(-50%, -50%) translateX(140%); opacity: 0; }
85% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
100% { transform: translate(-50%, -50%) translateX(0); opacity: 1; }
}
05Menu overlay toàn màn hình
Chuyện gì diễn ra trên màn hình
Bấm nút thì một tấm panel màu cam translateY từ 100% về 0 trong 600ms, phủ kín cả khung và mang theo danh sách link căn giữa, còn ba vạch icon chuyển nhanh hơn nhiều vì bản thân chúng chỉ là phần phụ so với panel đang trồi lên.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<div class="panel" aria-hidden="true">
<span class="panel__title">Menu</span>
<nav class="panel__links">
<a class="panel__link" href="#">Trang chủ</a>
<a class="panel__link" href="#">Giới thiệu</a>
<a class="panel__link" href="#">Sản phẩm</a>
<a class="panel__link" href="#">Liên hệ</a>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 600ms; // 패널이 올라오는 시간
$easing: $ease-spring;
$color: $subject-cream; // 줄·패널 글자 색
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 3; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.panel {
position: absolute; inset: 0; z-index: 2;
display: grid; place-items: center; gap: clamp(10px, 3.4vmin, 16px);
background: $stage-orange;
transform: translateY(100%);
transition: transform $duration $easing;
}
.panel__title { font-weight: 600; font-size: clamp(11px, 3.4vmin, 13px); line-height: 1; font-family: inherit; color: rgba($color, .7); letter-spacing: .06em; }
.panel__links { display: grid; gap: clamp(8px, 3vmin, 14px); text-align: center; }
.panel__link { font-weight: 700; font-size: clamp(15px, 5.2vmin, 20px); line-height: 1; font-family: inherit; color: $color; text-decoration: none; }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .panel { transform: translateY(0); }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.stage.is-demo .panel { animation: panel-loop $dur-loop $easing infinite; }
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes panel-loop {
0% { transform: translateY(100%); }
25% { transform: translateY(0); }
70% { transform: translateY(0); }
85% { transform: translateY(100%); }
100% { transform: translateY(100%); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .panel { animation: none; }
.bar, .panel { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 3;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.panel {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
gap: clamp(10px, 3.4vmin, 16px);
background: #ff4d1f;
transform: translateY(100%);
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.panel__title {
font-weight: 600;
font-size: clamp(11px, 3.4vmin, 13px);
line-height: 1;
font-family: inherit;
color: rgba(255, 247, 230, 0.7);
letter-spacing: 0.06em;
}
.panel__links {
display: grid;
gap: clamp(8px, 3vmin, 14px);
text-align: center;
}
.panel__link {
font-weight: 700;
font-size: clamp(15px, 5.2vmin, 20px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
text-decoration: none;
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .panel {
transform: translateY(0);
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .panel {
animation: panel-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes panel-loop {
0% {
transform: translateY(100%);
}
25% {
transform: translateY(0);
}
70% {
transform: translateY(0);
}
85% {
transform: translateY(100%);
}
100% {
transform: translateY(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .panel {
animation: none;
}
.bar, .panel {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FullscreenOverlay.module.scss";
export type FullscreenOverlayProps = {
title: string;
links: string[];
openLabel: string;
closeLabel: string;
/** 패널이 올라오는 시간 (기본 600ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FullscreenOverlay({
title,
links,
openLabel,
closeLabel,
duration = 600,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: FullscreenOverlayProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<div className={cls(styles.panel)} aria-hidden={!open}>
<span className={styles.panelTitle}>{title}</span>
<nav className={styles.panelLinks}>
{links.map((label) => (
<a key={label} className={styles.panelLink} href="#">
{label}
</a>
))}
</nav>
</div>
</div>
);
}05 · Menu overlay toàn màn hình — Diện tích đo được 99.997% · khung hình chuyển động 11/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
Hợp cho menu mobile có nhiều mục, nơi một khối dropdown nhỏ không đủ chỗ.
.panel {
position: absolute; inset: 0;
transform: translateY(100%);
transition: transform $duration $easing;
}
.stage.is-open .panel { transform: translateY(0); }
06Menu không cần JS với Popover API
Chuyển động thế nào
Nút .burger chỉ mang thuộc tính popovertarget trỏ đến id của menu, và chính thuộc tính popover trên div đó khiến trình duyệt tự lo việc mở, đóng khi bấm ra ngoài, và đẩy menu lên top layer — không có addEventListener nào điều khiển việc mở đóng thật.

<main class="stage">
<div class="anchor is-demo">
<button class="burger" popovertarget="menu-06" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<div id="menu-06" class="menu" popover aria-label="Menu">
<a class="menu__link" href="#">Trang chủ</a>
<a class="menu__link" href="#">Giới thiệu</a>
<a class="menu__link" href="#">Sản phẩm</a>
<a class="menu__link" href="#">Liên hệ</a>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 220ms; // 메뉴가 뜨는 시간
$easing: $ease-pop; // 살짝 튀며 나타난다
$color: $subject-ink; // 줄·메뉴 글자 색
@include stage($stage-yellow);
.stage { position: relative; }
// 다른 항목(01~05·07~09)과 같은 자리: 무대 중앙이 아니라 좌상단에 고정한다.
// 무대는 place-items:center 라 버튼이 세로 중앙에 오면, 폰 높이(200px)에서
// 메뉴가 아래로 펼쳐질 자리가 모자라 바닥을 넘친다 (실측: 320x200 폰 높이 넘침 14px, BD9)
.anchor {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
}
.burger {
position: relative; z-index: 2;
width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
// popover 속성이 없을 때(데모 기본값)는 anchor 아래 평범한 상자로 그린다
.menu {
position: absolute; top: calc(100% + 8px); left: 0; right: auto;
margin: 0; border: 0;
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-paper; color: $stage-ink; box-shadow: $shadow-raised;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left; // 규칙8: 앵커 아래에서 펼침
transition: opacity $duration $easing, transform $duration $easing;
}
.menu__link { display: block; font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-ink; text-decoration: none; }
// 진짜 Popover API 로 켜졌을 때는 top layer 위, 같은 자리에 뜨게 좌표만 다시 잡는다
.menu:popover-open {
position: fixed; inset: auto;
opacity: 1; transform: translateY(0) scale(1);
}
.anchor.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.anchor.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.anchor.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.anchor.is-demo .menu { animation: menu-loop $dur-loop $easing infinite; }
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; transform: translateY(-8px) scale(.96); }
15% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
85% { opacity: 0; transform: translateY(-8px) scale(.96); }
100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.anchor.is-demo .bar1, .anchor.is-demo .bar2, .anchor.is-demo .bar3, .anchor.is-demo .menu { animation: none; }
.bar, .menu { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.anchor {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
}
.burger {
position: relative;
z-index: 2;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #17141a;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1), opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: auto;
margin: 0;
border: 0;
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #ffffff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px) scale(0.96);
transform-origin: top left;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.menu__link {
display: block;
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
}
.menu:popover-open {
position: fixed;
inset: auto;
opacity: 1;
transform: translateY(0) scale(1);
}
.anchor.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.anchor.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.anchor.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
.anchor.is-demo .menu {
animation: menu-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
15% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.anchor.is-demo .bar1, .anchor.is-demo .bar2, .anchor.is-demo .bar3, .anchor.is-demo .menu {
animation: none;
}
.bar, .menu {
transition-duration: 1ms;
}
}// 데모는 anchor 에 .is-demo 를 걸어 keyframes 로 반복한다. 실제 열고 닫는 동작은
// popover 속성 하나가 다 한다 — JS 는 데모 반복을 멈추는 한 줄뿐이다.
(function () {
var anchor = document.querySelector('.anchor');
var burger = document.querySelector('.burger');
var menu = document.querySelector('.menu');
burger.addEventListener('click', function () { anchor.classList.remove('is-demo'); });
menu.addEventListener('toggle', function (e) {
burger.setAttribute('aria-label', e.newState === 'open' ? 'Đóng menu' : 'Mở menu');
});
})();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PopoverMenu.module.scss";
export type PopoverMenuProps = {
links: string[];
openLabel: string;
closeLabel: string;
label: string;
/** 메뉴가 뜨는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PopoverMenu({
links,
openLabel,
closeLabel,
label,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#17141a",
}: PopoverMenuProps) {
const ref = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function toggle() {
const el = ref.current as (HTMLDivElement & { showPopover?: () => void; hidePopover?: () => void }) | null;
if (el) {
if (!el.hasAttribute("popover")) el.setAttribute("popover", "manual");
if (open) el.hidePopover?.();
else el.showPopover?.();
}
setOpen((v) => !v);
}
return (
<div className={styles.anchor} style={vars}>
<button type="button" className={styles.burger} aria-label={open ? closeLabel : openLabel} onClick={toggle}>
<span className={`${styles.bar} ${open ? `${styles.bar1} ${styles.isOpen}` : styles.bar1}`} />
<span className={`${styles.bar} ${open ? `${styles.bar2} ${styles.isOpen}` : styles.bar2}`} />
<span className={`${styles.bar} ${open ? `${styles.bar3} ${styles.isOpen}` : styles.bar3}`} />
</button>
<div ref={ref} className={open ? `${styles.menu} ${styles.isOpen}` : styles.menu} aria-label={label}>
{links.map((l) => (
<a key={l} className={styles.menuLink} href="#">
{l}
</a>
))}
</div>
</div>
);
}06 · Menu không cần JS với Popover API — Diện tích đo được 12.977% · khung hình chuyển động 9/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Selector :popover-open định lại vị trí và opacity khi menu đang mở, còn bản chưa mở vẫn nằm yên dưới dạng khối tĩnh bên dưới nút.
.menu {
position: absolute; top: calc(100% + 8px); left: 0;
opacity: 0; transform: translateY(-8px) scale(.96);
transition: opacity $duration $easing, transform $duration $easing;
}
.menu:popover-open {
position: fixed; inset: auto;
opacity: 1; transform: translateY(0) scale(1);
}
07Link xuất hiện lần lượt
Bạn thấy gì
Mỗi .menu__link nhận một biến --i riêng đánh số thứ tự, và transition-delay tính bằng calc(var(--i) * 30ms) nên link đầu hiện trước, link cuối hiện sau đúng theo thứ tự xếp dọc chứ không cần bốn animation-delay viết tay.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<nav class="menu" aria-hidden="true">
<a class="menu__link" href="#" style="--i:0">Trang chủ</a>
<a class="menu__link" href="#" style="--i:1">Giới thiệu</a>
<a class="menu__link" href="#" style="--i:2">Sản phẩm</a>
<a class="menu__link" href="#" style="--i:3">Liên hệ</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 (값은 토큰에서 받는다) ──────────────────────
$stagger: 30ms; // 링크 사이 간격
$duration: 220ms; // 링크 하나가 나타나는 시간
$color: $subject-cream; // 줄·메뉴 글자 색
@include stage($stage-orange);
.stage { position: relative; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 2; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $dur-quick $ease-spring, opacity $dur-quick $ease-spring;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.menu {
position: absolute; top: clamp(52px, 16vmin, 64px); left: clamp(14px, 5vmin, 20px);
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; pointer-events: none;
transition: opacity $dur-instant linear;
}
.menu__link {
font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-ink; text-decoration: none;
opacity: 0; transform: translateY(-6px);
transition: opacity $duration $ease-spring, transform $duration $ease-spring;
transition-delay: calc(var(--i) * #{$stagger});
}
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .menu { opacity: 1; pointer-events: auto; }
.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .menu { animation: menu-loop $dur-loop linear infinite; }
.stage.is-demo .menu__link { animation: link-loop $dur-loop $ease-spring infinite; animation-delay: calc(var(--i) * #{$stagger}); }
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; }
15% { opacity: 1; }
70% { opacity: 1; }
85% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes link-loop {
0% { opacity: 0; transform: translateY(-6px); }
20% { opacity: 1; transform: translateY(0); }
65% { opacity: 1; transform: translateY(0); }
80% { opacity: 0; transform: translateY(-6px); }
100% { opacity: 0; transform: translateY(-6px); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3,
.stage.is-demo .menu, .stage.is-demo .menu__link { animation: none; }
.bar, .menu, .menu__link { transition-duration: 1ms; transition-delay: 0ms; }
}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;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 2;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: clamp(52px, 16vmin, 64px);
left: clamp(14px, 5vmin, 20px);
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
transition: opacity 140ms linear;
}
.menu__link {
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
opacity: 0;
transform: translateY(-6px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
transition-delay: calc(var(--i) * 30ms);
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .menu {
opacity: 1;
pointer-events: auto;
}
.stage.is-open .menu__link {
opacity: 1;
transform: translateY(0);
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .menu {
animation: menu-loop 2s linear infinite;
}
.stage.is-demo .menu__link {
animation: link-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
animation-delay: calc(var(--i) * 30ms);
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
}
15% {
opacity: 1;
}
70% {
opacity: 1;
}
85% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes link-loop {
0% {
opacity: 0;
transform: translateY(-6px);
}
20% {
opacity: 1;
transform: translateY(0);
}
65% {
opacity: 1;
transform: translateY(0);
}
80% {
opacity: 0;
transform: translateY(-6px);
}
100% {
opacity: 0;
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3,
.stage.is-demo .menu, .stage.is-demo .menu__link {
animation: none;
}
.bar, .menu, .menu__link {
transition-duration: 1ms;
transition-delay: 0ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StaggerLinks.module.scss";
export type StaggerLinksProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 링크 사이 간격 (기본 30ms) */
stagger?: number;
/** 링크 하나가 나타나는 시간 (기본 220ms) */
duration?: number;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--color"?: string };
type LinkVars = CSSProperties & { "--delay"?: string };
export default function StaggerLinks({
links,
openLabel,
closeLabel,
stagger = 30,
duration = 220,
color = "#fff7e6",
}: StaggerLinksProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label, i) => {
const linkVars: LinkVars = { "--delay": `${i * stagger}ms` };
return (
<a key={label} className={styles.menuLink} style={linkVars} href="#">
{label}
</a>
);
})}
</nav>
</div>
);
}07 · Link xuất hiện lần lượt — Diện tích đo được 12.543% · khung hình chuyển động 10/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng ở đâu
Mỗi link tự dịch chuyển từ translateY(-6px) về 0 trong 220ms, hợp cho menu dropdown có bốn đến sáu mục.
.menu__link {
opacity: 0; transform: translateY(-6px);
transition: opacity $duration $ease-spring, transform $duration $ease-spring;
transition-delay: calc(var(--i) * #{$stagger});
}
.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }
08Lan tỏa hình tròn từ icon
Chuyện gì diễn ra trên màn hình
Chỉ một thuộc tính clip-path thay đổi bán kính circle từ 0% lên 150% quanh đúng toạ độ góc trên phải nơi icon đứng, nên vòng tròn lan ra từ chính vị trí bấm chứ không phải từ tâm màn hình. 450ms đủ chậm để mắt thấy đường viền tròn loang ra, và vì chỉ một property đổi nên không có property nào khác cần theo dõi riêng.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<div class="reveal" aria-hidden="true">
<nav class="reveal__links">
<a class="reveal__link" href="#">Trang chủ</a>
<a class="reveal__link" href="#">Giới thiệu</a>
<a class="reveal__link" href="#">Sản phẩm</a>
<a class="reveal__link" href="#">Liên hệ</a>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: 450ms; // 원이 화면을 덮는 시간
$easing: $ease-spring;
$color: $subject-blue; // 줄·링크 글자 색 (paper 무대라 blue)
@include stage($stage-paper);
.stage { position: relative; overflow: hidden; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); right: clamp(14px, 5vmin, 20px);
z-index: 3; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $dur-quick $easing, opacity $dur-quick $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
// 아이콘 위치(오른쪽 위)를 원점으로 clip-path 원이 커지며 화면을 덮는다. 크기·위치는 그대로,
// clip-path 반지름만 바뀌므로 transform·opacity 규칙과 같은 값으로 취급한다 (레이아웃 재계산 없음)
.reveal {
position: absolute; inset: 0; z-index: 2;
display: grid; place-items: center;
background: $stage-ink;
clip-path: circle(0% at calc(100% - 30px) 30px);
transition: clip-path $duration $easing;
}
.reveal__links { display: grid; gap: clamp(8px, 3vmin, 14px); text-align: center; }
.reveal__link { font-weight: 700; font-size: clamp(15px, 5.2vmin, 20px); line-height: 1; font-family: inherit; color: $color; text-decoration: none; }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .reveal { clip-path: circle(150% at calc(100% - 30px) 30px); }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $easing infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $easing infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $easing infinite; }
.stage.is-demo .reveal { animation: reveal-loop $dur-loop $easing infinite; }
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes reveal-loop {
0% { clip-path: circle(0% at calc(100% - 30px) 30px); }
20% { clip-path: circle(150% at calc(100% - 30px) 30px); }
70% { clip-path: circle(150% at calc(100% - 30px) 30px); }
85% { clip-path: circle(0% at calc(100% - 30px) 30px); }
100% { clip-path: circle(0% at calc(100% - 30px) 30px); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .reveal { animation: none; }
.bar, .reveal { 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;
overflow: hidden;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
right: clamp(14px, 5vmin, 20px);
z-index: 3;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #2f6df6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.reveal {
position: absolute;
inset: 0;
z-index: 2;
display: grid;
place-items: center;
background: #17141a;
clip-path: circle(0% at calc(100% - 30px) 30px);
transition: clip-path 450ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.reveal__links {
display: grid;
gap: clamp(8px, 3vmin, 14px);
text-align: center;
}
.reveal__link {
font-weight: 700;
font-size: clamp(15px, 5.2vmin, 20px);
line-height: 1;
font-family: inherit;
color: #2f6df6;
text-decoration: none;
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .reveal {
clip-path: circle(150% at calc(100% - 30px) 30px);
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .reveal {
animation: reveal-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes reveal-loop {
0% {
clip-path: circle(0% at calc(100% - 30px) 30px);
}
20% {
clip-path: circle(150% at calc(100% - 30px) 30px);
}
70% {
clip-path: circle(150% at calc(100% - 30px) 30px);
}
85% {
clip-path: circle(0% at calc(100% - 30px) 30px);
}
100% {
clip-path: circle(0% at calc(100% - 30px) 30px);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .reveal {
animation: none;
}
.bar, .reveal {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CircleReveal.module.scss";
export type CircleRevealProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 원이 화면을 덮는 시간 (기본 450ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function CircleReveal({
links,
openLabel,
closeLabel,
duration = 450,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: CircleRevealProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<div className={cls(styles.reveal)} aria-hidden={!open}>
<nav className={styles.revealLinks}>
{links.map((label) => (
<a key={label} className={styles.revealLink} href="#">
{label}
</a>
))}
</nav>
</div>
</div>
);
}08 · Lan tỏa hình tròn từ icon — Diện tích đo được 99.997% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Chỗ dùng
.reveal {
position: absolute; inset: 0;
clip-path: circle(0% at calc(100% - 30px) 30px);
transition: clip-path $duration $easing;
}
.stage.is-open .reveal {
clip-path: circle(150% at calc(100% - 30px) 30px);
}
09Làm mờ và tối nền
Chuyển động thế nào
Toàn bộ .page phía sau nhận filter: blur(6px) brightness(.6) cùng lúc menu mở ra, và vì hai giá trị đó nằm chung một thuộc tính filter nên chỉ cần một transition, không phải hai.

<main class="stage is-demo">
<div class="page">
<span class="page__line page__line--1"></span>
<span class="page__line page__line--2"></span>
<span class="page__line page__line--3"></span>
</div>
<button class="burger" type="button" aria-label="Mở menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<nav class="menu" aria-hidden="true">
<a class="menu__link" href="#">Trang chủ</a>
<a class="menu__link" href="#">Giới thiệu</a>
<a class="menu__link" href="#">Sản phẩm</a>
<a class="menu__link" href="#">Liên hệ</a>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 (값은 토큰에서 받는다) ──────────────────────
$blur: 6px; // 배경이 흐려지는 정도. 진하게 10px
$dim: .4; // 배경이 어두워지는 정도(0~1)
$color: $subject-cream; // 줄·메뉴 글자 색
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; }
.page {
position: absolute; inset: 0;
display: grid; align-content: center; gap: clamp(10px, 4vmin, 16px);
padding: clamp(20px, 8vmin, 32px);
transition: filter $dur-base $ease-spring;
}
.page__line { display: block; height: clamp(8px, 2.6vmin, 12px); border-radius: 6px; background: rgba($color, .5); }
.page__line--1 { width: 70%; }
.page__line--2 { width: 92%; }
.page__line--3 { width: 55%; }
.burger {
position: absolute; top: clamp(14px, 5vmin, 20px); left: clamp(14px, 5vmin, 20px);
z-index: 3; width: clamp(34px, 10vmin, 42px); height: clamp(34px, 10vmin, 42px);
border: 0; border-radius: $r-control; background: transparent;
padding: 0; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute; left: 50%; top: 50%;
width: clamp(20px, 6.4vmin, 26px); height: 3px; border-radius: 2px;
background: $color;
transition: transform $dur-quick $ease-spring, opacity $dur-quick $ease-spring;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
.menu {
position: absolute; top: clamp(52px, 16vmin, 64px); left: clamp(14px, 5vmin, 20px);
z-index: 2;
display: grid; gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: $r-card; background: $stage-paper; box-shadow: $shadow-raised;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left; // 규칙8: 버튼 모서리에서 펼침
pointer-events: none;
transition: opacity $dur-base $ease-spring, transform $dur-base $ease-spring;
}
.menu__link { font-weight: 600; font-size: clamp(11px, 3.6vmin, 13px); line-height: 1; font-family: inherit; color: $stage-ink; text-decoration: none; }
.stage.is-open .page { filter: blur($blur) brightness(1 - $dim); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
.stage.is-demo .page { animation: page-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .bar1 { animation: bar1-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .bar2 { animation: bar2-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .bar3 { animation: bar3-loop $dur-loop $ease-spring infinite; }
.stage.is-demo .menu { animation: menu-loop $dur-loop $ease-spring infinite; }
@keyframes page-loop {
0% { filter: blur(0) brightness(1); }
15% { filter: blur($blur) brightness(1 - $dim); }
70% { filter: blur($blur) brightness(1 - $dim); }
85% { filter: blur(0) brightness(1); }
100% { filter: blur(0) brightness(1); }
}
@keyframes bar1-loop {
0% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
85% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(-7px) rotate(0deg); }
}
@keyframes bar2-loop {
0% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
15% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
70% { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
85% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
100% { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
}
@keyframes bar3-loop {
0% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
15% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
70% { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
85% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
100% { transform: translate(-50%, -50%) translateY(7px) rotate(0deg); }
}
@keyframes menu-loop {
0% { opacity: 0; transform: translateY(-8px) scale(.96); }
15% { opacity: 1; transform: translateY(0) scale(1); }
70% { opacity: 1; transform: translateY(0) scale(1); }
85% { opacity: 0; transform: translateY(-8px) scale(.96); }
100% { opacity: 0; transform: translateY(-8px) scale(.96); }
}
// ── 접근성 ─────────────────────────────────────────
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .page, .stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu { animation: none; }
.page, .bar, .menu { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
overflow: hidden;
}
.page {
position: absolute;
inset: 0;
display: grid;
align-content: center;
gap: clamp(10px, 4vmin, 16px);
padding: clamp(20px, 8vmin, 32px);
transition: filter 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.page__line {
display: block;
height: clamp(8px, 2.6vmin, 12px);
border-radius: 6px;
background: rgba(255, 247, 230, 0.5);
}
.page__line--1 {
width: 70%;
}
.page__line--2 {
width: 92%;
}
.page__line--3 {
width: 55%;
}
.burger {
position: absolute;
top: clamp(14px, 5vmin, 20px);
left: clamp(14px, 5vmin, 20px);
z-index: 3;
width: clamp(34px, 10vmin, 42px);
height: clamp(34px, 10vmin, 42px);
border: 0;
border-radius: 12px;
background: transparent;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.bar {
position: absolute;
left: 50%;
top: 50%;
width: clamp(20px, 6.4vmin, 26px);
height: 3px;
border-radius: 2px;
background: #fff7e6;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.bar1 {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
.bar2 {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
.bar3 {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
.menu {
position: absolute;
top: clamp(52px, 16vmin, 64px);
left: clamp(14px, 5vmin, 20px);
z-index: 2;
display: grid;
gap: clamp(6px, 2.2vmin, 10px);
width: clamp(120px, 40vmin, 160px);
padding: clamp(12px, 4vmin, 16px);
border-radius: 16px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px) scale(0.96);
transform-origin: top left;
pointer-events: none;
transition: opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.menu__link {
font-weight: 600;
font-size: clamp(11px, 3.6vmin, 13px);
line-height: 1;
font-family: inherit;
color: #17141a;
text-decoration: none;
}
.stage.is-open .page {
filter: blur(6px) brightness(0.6);
}
.stage.is-open .bar1 {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
.stage.is-open .bar2 {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
.stage.is-open .bar3 {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
.stage.is-open .menu {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
.stage.is-demo .page {
animation: page-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar1 {
animation: bar1-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar2 {
animation: bar2-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .bar3 {
animation: bar3-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
.stage.is-demo .menu {
animation: menu-loop 2s cubic-bezier(0.2, 0.8, 0.2, 1) infinite;
}
@keyframes page-loop {
0% {
filter: blur(0) brightness(1);
}
15% {
filter: blur(6px) brightness(0.6);
}
70% {
filter: blur(6px) brightness(0.6);
}
85% {
filter: blur(0) brightness(1);
}
100% {
filter: blur(0) brightness(1);
}
}
@keyframes bar1-loop {
0% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(45deg);
}
85% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(-7px) rotate(0deg);
}
}
@keyframes bar2-loop {
0% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
15% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
70% {
transform: translate(-50%, -50%) scaleX(0);
opacity: 0;
}
85% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
100% {
transform: translate(-50%, -50%) scaleX(1);
opacity: 1;
}
}
@keyframes bar3-loop {
0% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
15% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
70% {
transform: translate(-50%, -50%) translateY(0) rotate(-45deg);
}
85% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
100% {
transform: translate(-50%, -50%) translateY(7px) rotate(0deg);
}
}
@keyframes menu-loop {
0% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
15% {
opacity: 1;
transform: translateY(0) scale(1);
}
70% {
opacity: 1;
transform: translateY(0) scale(1);
}
85% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
100% {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .page, .stage.is-demo .bar1, .stage.is-demo .bar2, .stage.is-demo .bar3, .stage.is-demo .menu {
animation: none;
}
.page, .bar, .menu {
transition-duration: 1ms;
}
}(function () {
var stage = document.querySelector('.stage');
var burger = document.querySelector('.burger');
var open = false;
burger.addEventListener('click', function () {
stage.classList.remove('is-demo');
open = !open;
stage.classList.toggle('is-open', open);
burger.setAttribute('aria-label', open ? 'Đóng menu' : 'Mở menu');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BackdropBlurDim.module.scss";
export type BackdropBlurDimProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 배경이 흐려지는 정도 (기본 6px, 진하게 10px) */
blur?: number;
/** 배경이 어두워지는 정도 0~1 (기본 .4) */
dim?: number;
color?: string;
};
type Vars = CSSProperties & { "--color"?: string; "--blur"?: string; "--brightness"?: string };
export default function BackdropBlurDim({
links,
openLabel,
closeLabel,
blur = 6,
dim = 0.4,
color = "#fff7e6",
}: BackdropBlurDimProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--color": color, "--blur": `${blur}px`, "--brightness": `${1 - dim}` };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<div className={cls(styles.page)}>
<span className={`${styles.pageLine} ${styles.pageLine1}`} />
<span className={`${styles.pageLine} ${styles.pageLine2}`} />
<span className={`${styles.pageLine} ${styles.pageLine3}`} />
</div>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label) => (
<a key={label} className={styles.menuLink} href="#">
{label}
</a>
))}
</nav>
</div>
);
}09 · Làm mờ và tối nền — Diện tích đo được 20.247% · khung hình chuyển động 8/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · Xem mật khẩu
Dùng khi nào
Cách này khác backdrop-filter ở chỗ nó làm mờ chính nội dung trang chứ không phải một lớp trong suốt nằm trên nội dung, nên không phụ thuộc việc menu có trong suốt hay không.
$blur: 6px;
$dim: .4;
.page {
transition: filter $dur-base $ease-spring;
}
.stage.is-open .page {
filter: blur($blur) brightness(1 - $dim);
}
Chỗ dễ hỏng ở đâu? — cái bẫy
filter và backdrop-filter
Cái bẫy đầu tiên nằm ở chỗ nhiều người muốn đổi mục 09 từ filter sang backdrop-filter để chỉ làm mờ lớp nền phía sau một menu trong suốt thay vì mờ luôn cả trang, nhưng mức hỗ trợ trình duyệt của hai thuộc tính đó không giống nhau — backdrop-filter trên MDN ghi rõ nó chạy được từ Chrome 76, từ Safari 18 không cần tiền tố -webkit, còn Firefox chỉ hỗ trợ từ bản 103. Nếu trang của bạn còn phải chạy trên Firefox cũ hơn 103, filter thường trên .page như mục 09 đang dùng vẫn là lựa chọn chắc tay hơn.
Selector :has thay cho JS
Cái bẫy thứ hai là ý định bỏ hẳn nút .burger và JS, dùng selector :has() để cha tự đổi kiểu theo trạng thái con — kiểu .stage:has(.menu:target) hoặc kết hợp checkbox ẩn — nhưng :has() trên MDN cho thấy nó mới chạy từ Chrome 105, từ Safari 15.4, và từ Firefox 121, nên một site cần chạy trên trình duyệt cũ hơn các mốc đó sẽ mất hẳn menu nếu không có phần dự phòng. Chín file trong zip vẫn giữ một dòng JS ngắn để toggle class .is-open, trừ mục 06 dùng thẳng popover, và nếu bạn muốn mở file lên đọc ngay thì mật khẩu của zip là 2cpj9dyd.
Khung iframe 480×300
Cái bẫy thứ ba nằm ở chính lưới xem trước phía trên: khung iframe 480×300 quá nhỏ để bấm và hover được như trên bản thật, nên mỗi ô tự chuyển sang vòng lặp .is-demo dài hai giây thay vì chờ bạn bấm. Bạn chỉ xem chuyển động lặp ở lưới đó thôi; muốn thấy popover của mục 06 tự đóng khi bấm ra ngoài hay vòng tròn của mục 08 loang đúng từ vị trí icon thật, hãy mở riêng file vanilla trong tab của bạn rồi tự bấm vào đó.
Khả năng tiếp cận
Cách tắt chuyển động ở cả chín mục
Cả chín demo đều tắt chuyển động khi prefers-reduced-motion bật, nhưng cách tắt giống nhau ở mọi mục: animation lặp .is-demo bị gỡ hoàn toàn, còn transition-duration của .bar, .menu và các khối phụ như .panel, .reveal, .page bị ép về 1ms thay vì 0ms, để trạng thái mở đóng vẫn đổi đúng lúc bấm mà không có bước chuyển nào lọt vào mắt. Riêng mục 06 không cần đổi gì thêm vì việc mở đóng vốn đã do trình duyệt xử lý qua popover chứ không qua animation của site.
@media (prefers-reduced-motion: reduce) {
.stage.is-demo .bar1,
.stage.is-demo .bar2,
.stage.is-demo .bar3,
.stage.is-demo .menu { animation: none; }
.bar, .menu { transition-duration: 1ms; }
}
Xem thêm hiệu ứng CSS khác trong chuyên mục CSS, và trang giới thiệu nói rõ site này thực chất là gì.
FAQ
Css hamburger menu animation có cần JavaScript không?
Gần như không. Tám trong chín mục chỉ dùng một dòng JS ngắn để toggle class .is-open, còn bản thân chuyển động chạy hoàn toàn bằng transition và transform của CSS. Riêng mục 06 dùng thuộc tính popover của trình duyệt nên việc mở đóng không chạm đến JS nào cả.
Nên chọn kiểu bars-to-x, spin hay arrow-morph?
Bars-to-x ở mục 01 là mặc định an toàn cho header thường. Spin ở mục 02 hợp khi bạn muốn nhấn thêm một chút, vì cả khối icon tự xoay chứ không chỉ ba vạch. Arrow-morph ở mục 03 chỉ nên dùng khi nút đó thật sự kiêm luôn chức năng quay lại, vì hình mũi tên gợi ý sai nếu bấm vào chỉ để mở menu.
Có dùng chung với Tailwind hoặc một dự án React được không?
Được. Class tiện ích của Tailwind không đụng vào các @keyframes hay biến SCSS mà đoạn code này khai báo, nên hai bên không giẫm lên nhau. Thư mục react/ trong zip có một component cho mỗi trong chín mục, nhận đúng các biến như $duration hay $easing qua props, nên bạn không cần viết lại phần chuyển động khi chuyển sang React.








