9 CSS Hamburger Menu Animations, No Library
A css hamburger menu animation is the motion a three-bar icon plays as a menu opens, built from transform and opacity rather than a JavaScript library.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Bars morph to X
- 02 Spin into X
- 03 Morph to arrow
- 04 Middle bar slides out
- 05 Fullscreen overlay menu
- 06 Popover API menu, zero JS
- 07 Staggered link reveal
- 08 Circle reveal from the icon
- 09 Backdrop blur and dim
A hamburger menu icon css animation is the same three bars in 01 through 04; only the geometry of how they meet changes — a plain X, a spin, an arrow, and a version that clears the middle bar first before the other two cross. 05 and 06 change what shows up behind the icon instead: a fullscreen panel that slides up, and a browser-native popover that needs no menu logic written in CSS at all. 07 through 09 layer motion onto whichever shape you picked — links that arrive one after another, a reveal shaped like the icon's own tap point, and a background that blurs to hand focus to the menu. Watch all nine loop in the grid above; every code block below keeps only the lines that make each one move, and the untrimmed files are in the zip. When a panel like 05 grows past covering the screen into replacing it, that's the territory of the CSS page transitions, and when a single row expands in place instead of a menu taking over, the CSS accordion animations are the closer fit.
01Bars morph to X
What you see
The top and bottom bars rotate 45 degrees into an X while the middle bar fades out beneath them, the whole move riding on transform and opacity over 300ms.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Products</a>
<a class="menu__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Bars morph to X — Measured area 9.553% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where a css hamburger icon animation fits
It's the default hamburger button for a header — the shape most people expect before they've even read the icon.
.bar {
position: absolute; left: 50%; top: 50%;
width: 24px; height: 3px; border-radius: 2px;
background: $color;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.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); }
02Spin into X
The motion
The whole icon spins a full 180 degrees while the bars fold into the same X as item 01, so the rotation reads as one continuous gesture rather than two separate moves stacked on top of each other.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Products</a>
<a class="menu__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Spin into X — Measured area 13.016% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It suits mobile navigation that wants opening the menu to feel more deliberate than a plain fade.
.burger {
transform: rotate(0deg);
transition: transform $duration $easing;
}
.stage.is-open .burger { transform: rotate(180deg); }
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.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); }
03Morph to arrow
What it does
The three bars narrow and tilt until they read as a left-pointing arrow instead of an X, using the same rotate approach as item 01 but with a shorter travel distance and each bar's transform-origin pinned to its far end.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Products</a>
<a class="menu__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Morph to arrow — Measured area 13.206% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It fits a menu button that doubles as a back action, since the icon itself hints at what tapping it will do.
.bar {
position: absolute; right: 50%; top: 50%;
transform-origin: right center;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar1 { transform: translate(50%, -50%) translateY(-7px); }
.bar2 { transform: translate(50%, -50%) scaleX(1); }
.bar3 { transform: translate(50%, -50%) translateY(7px); }
.stage.is-open .bar1 { transform: translate(50%, -50%) translateY(-3px) rotate(-35deg); }
.stage.is-open .bar2 { transform: translate(50%, -50%) scaleX(.7); }
.stage.is-open .bar3 { transform: translate(50%, -50%) translateY(3px) rotate(35deg); }
04Middle bar slides out
What you see
The middle bar slides sideways and fades out before the top and bottom bars rotate into their X, so the three bars never cross paths mid-transition the way they do in item 01. It works best in wide headers with room to spare, where that extra half-step has space to actually read.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Products</a>
<a class="menu__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Middle bar slides out — Measured area 13.229% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
In the code
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 {
transform: translate(-50%, -50%) translateX(0);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.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); }
05Fullscreen overlay menu
The motion
A full-screen color panel rises from the bottom over 600ms via transform: translateY(), covering the page as the bars behind it collapse into an X.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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="#">Home</a>
<a class="panel__link" href="#">About</a>
<a class="panel__link" href="#">Products</a>
<a class="panel__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Fullscreen overlay menu — Measured area 99.997% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It's built for mobile navigation carrying more links than a small dropdown can hold without feeling cramped.
.panel {
position: absolute; inset: 0;
transform: translateY(100%);
transition: transform $duration $easing;
}
.stage.is-open .panel { transform: translateY(0); }
.bar1 { transform: translate(-50%, -50%) translateY(-7px); }
.bar2 { transform: translate(-50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(-50%, -50%) translateY(7px); }
.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); }
06Popover API menu, zero JS
What it does
The menu opens with nothing but the popover attribute on the panel and popovertarget on the button, so the open state, the light dismiss on an outside click, and the top-layer stacking all come from the browser instead of a script.

<main class="stage">
<div class="anchor is-demo">
<button class="burger" popovertarget="menu-06" type="button" aria-label="Open 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="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Products</a>
<a class="menu__link" href="#">Contact</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' ? 'Close menu' : 'Open 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 · Popover API menu, zero JS — Measured area 12.977% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
That makes it a good fit for static sites that need a working menu without shipping a JavaScript build step at all.
<button class="burger" popovertarget="menu" type="button" aria-label="Open menu">
<span class="bar bar1"></span>
<span class="bar bar2"></span>
<span class="bar bar3"></span>
</button>
<div id="menu" class="menu" popover aria-label="Menu">
<a class="menu__link" href="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Contact</a>
</div>
07Staggered link reveal
What you see
Once the panel is open, each link fades in roughly 30ms after the one above it, using a single custom property multiplied into transition-delay instead of a loop of setTimeout calls.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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">Home</a>
<a class="menu__link" href="#" style="--i:1">About</a>
<a class="menu__link" href="#" style="--i:2">Products</a>
<a class="menu__link" href="#" style="--i:3">Contact</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 ? 'Close menu' : 'Open 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 · Staggered link reveal — Measured area 12.543% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where it fits
It reads well on dropdown menus with four to six links, where an instant reveal feels flat and a full stagger animation feels like overkill.
.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 { opacity: 1; pointer-events: auto; }
.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }
08Circle reveal from the icon
The motion
A circle grows outward from the icon's own position until it covers the screen, driven by animating a single clip-path: circle() value rather than swapping in a whole new stacking layer.

<main class="stage is-demo">
<button class="burger" type="button" aria-label="Open 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="#">Home</a>
<a class="reveal__link" href="#">About</a>
<a class="reveal__link" href="#">Products</a>
<a class="reveal__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Circle reveal from the icon — Measured area 99.997% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
Good places for it
It suits an icon button next to a logo, or a landing page that wants the reveal itself to carry some visual weight.
.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);
}
.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); }
09Backdrop blur and dim
What it does
The page behind the menu blurs by 6px and dims by about 40 percent as the panel opens, both driven by a single filter transition on the content itself rather than a separate overlay element.

<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="Open 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="#">Home</a>
<a class="menu__link" href="#">About</a>
<a class="menu__link" href="#">Products</a>
<a class="menu__link" href="#">Contact</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 ? 'Close menu' : 'Open 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 · Backdrop blur and dim — Measured area 20.247% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
When to use it
It's built for content-heavy pages, where an overlay menu needs to visually pull away from everything still readable underneath it.
.page {
transition: filter $dur-base $ease-spring;
}
.stage.is-open .page { filter: blur($blur) brightness(1 - $dim); }
.menu {
opacity: 0;
transform: translateY(-8px) scale(.96);
transition: opacity $dur-base $ease-spring, transform $dur-base $ease-spring;
}
.stage.is-open .menu { opacity: 1; transform: translateY(0) scale(1); }
Where it breaks — the traps
clip-path and backdrop-filter support
08's reveal is a single animating clip-path, and support for clip-path itself is safe in every modern browser today, as MDN's clip-path reference confirms. 09 could have reached for backdrop-filter on a floating overlay instead of filtering .page directly, but backdrop-filter only ships unprefixed in Safari from version 18, alongside Chrome 76+ and Firefox 103+, per MDN's backdrop-filter browser compatibility table — filtering the actual content node sidesteps that gap, which is the real reason the demo does it that way rather than layering a blurred sheet on top. The delay math behind 07's one-after-another links is worth borrowing outside a menu too — it runs vertically, step by step, through the CSS timeline animations.
Trap two — popover needs styling
The second trap is item 06's popover: popovertarget and the popover attribute hand you the open state, the outside-click dismiss, and the top-layer stacking for free, but they don't hand you a transition unless you also style the :popover-open selector — skip that, and the panel still works — it just snaps open with no motion at all, which looks like a bug rather than a missing style. The zip's password is 2cpj9dyd, sitting in this paragraph the same plain way it sits folded into the SCSS file itself, easy to miss if you're skimming for a code block instead of reading the sentence.
Trap three — the preview loop
The third trap is the preview above: the 3×3 grid sits inside a fixed, non-scrollable iframe, so every icon there opens and closes on its own two-second loop instead of waiting for a tap — watch it rather than trying to click any of the nine squares.
Accessibility
Reduced motion
prefers-reduced-motion strips something different from each family here rather than one blanket "turn everything off." 01 through 05 and 08 keep every open and closed position exactly where it was; they just collapse the transition duration down to near-nothing, since a menu that can't move from closed to open at all would be broken rather than calmer. 09's blur and dim behave the same way — the page still darkens and softens, only instantly instead of over a fade. 06's popover motion collapses to the same instant swap, since the actual open and close mechanism is the popover attribute itself, not the transition riding on top of it. 07 is the one exception worth calling out on its own: the stagger's transition-delay resets to zero, so every link appears together instead of one after another, because a 30ms cascade reads as decoration once someone has asked their system to skip it. MDN's prefers-reduced-motion guide has the full browser support table.
@media (prefers-reduced-motion: reduce) {
.bar, .menu, .panel, .reveal, .page { transition-duration: 1ms; }
.menu__link { transition-duration: 1ms; transition-delay: 0ms; }
}
A css hamburger menu animation codepen is easy to find, but more of these transition patterns live in the CSS category, and what this project actually is gets explained on the about page.
FAQ
Does a css hamburger menu animation need JavaScript?
Only to toggle a class in most of these — items 01 through 05 and 07 through 09 swap a class like is-open on click, and that single click handler is the only script involved, since the motion itself is transform, opacity, filter, and clip-path written in CSS. Item 06 needs no script at all, because popovertarget and popover handle the open state, the outside-click dismiss, and the Escape key on their own.
What's the difference between a plain overlay panel and a clip-path reveal?
An overlay like item 05's panel is a whole extra element that slides or fades into place on top of everything else, while item 08's clip-path animates the visible shape of one element that's already sitting there, growing a circle instead of moving a box across the screen. Clip-path tends to be cheaper to animate since the browser only recalculates the clipping region rather than layout, but the reveal shape is limited to whatever clip-path can describe — a circle here, anchored to the icon's own corner.
Can an html css burger menu animation run inside a React or Vue component?
Yes. Every effect here is scoped to class names and CSS custom properties, so a component only needs to add or remove is-open on click; nothing in the SCSS reaches into the DOM beyond what a framework already renders for you. The zip's react/ folder has a working version of each item as a small component, in case you'd rather start from that than wire up the class toggle by hand. On wider screens the same navigation usually wants to unfold rather than take over, and that shape is collected separately in the CSS dropdown menu animations.









