【JS最小・CSSだけ】css ハンバーガーメニュー アニメーション9選
css ハンバーガーメニュー アニメーション は、3本線のアイコンをクリックした瞬間に何が起こるかを決める短い動きです。この記事では9種類を選び、CSS の transform と opacity だけで実装しました(JS はクリックを受け取る数行だけ)。
01 hamburger-menu-bars-to-x
02 hamburger-menu-spin-to-x
03 hamburger-menu-arrow-morph
04 hamburger-menu-slide-out-middle
05 hamburger-menu-fullscreen-overlay
06 hamburger-menu-popover-menu-no-js
07 hamburger-menu-stagger-links
08 hamburger-menu-circle-reveal-open
09 hamburger-menu-backdrop-blur-dim
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 3本線がXに変形
- 02 回転しながらX
- 03 矢印に変形
- 04 中央線がスライドアウト
- 05 全画面オーバーレイメニュー
- 06 Popover APIでJS0行メニュー
- 07 リンクが順番に表示
- 08 アイコンから円形に広がる
- 09 背景ぼかし+暗転
グリッドの9つは iframe の中で2秒周期に自動で開閉を繰り返すだけで、クリックしても反応しません。動きを見比べるための「眺める」デモで、実際に押して確かめたいときは下にある項目ごとのデモを開いてください。並び順は効果の種類ではなく、実務でよく使う場面の順です。最初の4つはヘッダーに置く定番のアイコン変形(01〜04)、次の3つは開いた後のパネルやリンクの見せ方(05〜07)、最後の2つは背景側に演出を足すものです(08〜09)。9つとも JavaScript はクリックを受け取る数行だけで、開閉そのものは CSS の transition と transform が担っています。 05のようにメニューが画面を覆う段階を越えて画面ごと入れ替える必要が出たらcss ページ遷移 アニメーション9選の領域になり、メニューではなく項目ひとつがその場で開く場合はCSS アコーディオン アニメーション 9選が近いです。
013本線がXに変形
何が起きるか
上下の線がそれぞれ45度ずつ回転して重なりXになり、中央の線はscaleXとopacityで同時に消えます。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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="#">ホーム</a>
<a class="menu__link" href="#">会社概要</a>
<a class="menu__link" href="#">製品</a>
<a class="menu__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BarsToX.module.scss";
export type BarsToXProps = {
links: string[];
openLabel: string;
closeLabel: string;
/** 줄이 X로 겹치는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function BarsToX({
links,
openLabel,
closeLabel,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: BarsToXProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = (base: string) => (open ? `${base} ${styles.isOpen}` : base);
return (
<div className={styles.stage} style={vars}>
<button
type="button"
className={cls(styles.burger)}
aria-label={open ? closeLabel : openLabel}
onClick={() => setOpen((v) => !v)}
>
<span className={`${styles.bar} ${styles.bar1}`} />
<span className={`${styles.bar} ${styles.bar2}`} />
<span className={`${styles.bar} ${styles.bar3}`} />
</button>
<nav className={cls(styles.menu)} aria-hidden={!open}>
{links.map((label) => (
<a key={label} className={styles.menuLink} href="#">
{label}
</a>
))}
</nav>
</div>
);
}01 · 3本線がXに変形 — 計測 累積面積 9.553% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
300msのspringイージング以外に特殊な仕掛けがないので、ヘッダーの基本ボタンとして最初に選ぶならこの形です。
.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); }
.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); }
02回転しながらX
動きの内容
線がXに変形するのと同時に、アイコン全体が180度回ります。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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="#">ホーム</a>
<a class="menu__link" href="#">会社概要</a>
<a class="menu__link" href="#">製品</a>
<a class="menu__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · 回転しながらX — 計測 累積面積 13.016% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
ease-popで終わりに少しだけ弾みを付けているので、強調したいモバイルナビの開閉ボタンに向いています。
$duration: 350ms; // アイコンが回る時間
$easing: $ease-pop; // 最後に少し弾む
.burger {
transform: rotate(0deg);
transition: transform $duration $easing;
}
.stage.is-open .burger { transform: rotate(180deg); }
.stage.is-open .bar1 { transform: translate(-50%, -50%) translateY(0) rotate(45deg); }
.stage.is-open .bar2 { transform: translate(-50%, -50%) scaleX(0); opacity: 0; }
.stage.is-open .bar3 { transform: translate(-50%, -50%) translateY(0) rotate(-45deg); }
03矢印に変形
どんな動きか
右端を軸に3本の線が閉じていくと、開くボタンではなく戻る矢印のような形になります。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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="#">ホーム</a>
<a class="menu__link" href="#">会社概要</a>
<a class="menu__link" href="#">製品</a>
<a class="menu__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · 矢印に変形 — 計測 累積面積 13.206% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
中央の線は消えずscaleX(.7)で少し縮むだけなので、戻る操作を兼ねるメニューボタンに使えます。
$duration: 250ms; // 矢印に狭まる時間
$easing: $ease-spring;
// 右端を軸に回転させ、左端が閉じると "<" の形になる
.bar {
transform-origin: right center;
}
.bar1 { transform: translate(50%, -50%) translateY(-7px) rotate(0deg); }
.bar2 { transform: translate(50%, -50%) scaleX(1); opacity: 1; }
.bar3 { transform: translate(50%, -50%) translateY(7px) rotate(0deg); }
.stage.is-open .bar1 { transform: translate(50%, -50%) translateY(-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); }
04中央線がスライドアウト
何が起きるか
開くと中央の線だけが横に抜けて消え、そのあとで上下の線がXに重なります。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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="#">ホーム</a>
<a class="menu__link" href="#">会社概要</a>
<a class="menu__link" href="#">製品</a>
<a class="menu__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · 中央線がスライドアウト — 計測 累積面積 13.229% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
向いている場面
keyframeでは中央線が8%地点、上下線が15%地点と、少しだけタイミングをずらしているのがこの項目の要点です。
$duration: 280ms; // 中央線が抜ける時間
.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); }
// 中央線が先に抜け、そのあと上下線がXに重なる。閉じるときは逆順
@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; }
}
05全画面オーバーレイメニュー
動きの内容
ボタンを押すと画面全体を覆う色パネルが下から600msかけてせり上がり、メニューが現れます。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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">メニュー</span>
<nav class="panel__links">
<a class="panel__link" href="#">ホーム</a>
<a class="panel__link" href="#">会社概要</a>
<a class="panel__link" href="#">製品</a>
<a class="panel__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · 全画面オーバーレイメニュー — 計測 累積面積 99.997% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
stageにoverflow: hiddenをかけているので、パネルがせり上がっている間もページの他の部分は動きません。
$duration: 600ms; // パネルがせり上がる時間
.stage { position: relative; overflow: hidden; }
.panel {
position: absolute; inset: 0; z-index: 2;
background: $stage-orange;
transform: translateY(100%);
transition: transform $duration $easing;
}
.stage.is-open .panel { transform: translateY(0); }
06Popover APIでJS0行メニュー
どんな動きか
popover属性を付けるだけでメニューが開き、外側をクリックすると自動的に閉じます。

<main class="stage">
<div class="anchor is-demo">
<button class="burger" popovertarget="menu-06" type="button" aria-label="メニューを開く">
<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="メニュー">
<a class="menu__link" href="#">ホーム</a>
<a class="menu__link" href="#">会社概要</a>
<a class="menu__link" href="#">製品</a>
<a class="menu__link" href="#">お問い合わせ</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' ? 'メニューを閉じる' : 'メニューを開く');
});
})();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でJS0行メニュー — 計測 累積面積 12.977% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
実際に開閉を切り替えているのは:popover-openセレクタで座標を上書きする部分だけで、開閉のJavaScriptは1行も要りません。
// popover 属性が付いていない間(デモの初期値)は anchor の下に普通の箱として描く
.menu {
position: absolute; top: calc(100% + 8px); left: 0; right: auto;
margin: 0; border: 0;
opacity: 0; transform: translateY(-8px) scale(.96); transform-origin: top left;
transition: opacity $duration $easing, transform $duration $easing;
}
// 本物の Popover API で開くと top layer の上、同じ位置に座標だけ再計算する
.menu:popover-open {
position: fixed; inset: auto;
opacity: 1; transform: translateY(0) scale(1);
}
07リンクが順番に表示
何が起きるか
メニューが開くと、リンクが30ms間隔で上から順に現れます。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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">ホーム</a>
<a class="menu__link" href="#" style="--i:1">会社概要</a>
<a class="menu__link" href="#" style="--i:2">製品</a>
<a class="menu__link" href="#" style="--i:3">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · リンクが順番に表示 — 計測 累積面積 12.543% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
css ドロップダウン メニューでの使いどころ
transition-delayにCSSカスタムプロパティ--iを掛け合わせているだけなので、リンクの数が増えてもJavaScript側の計算は増えません。
$stagger: 30ms; // リンクの間隔
$duration: 220ms; // リンク1つが現れる時間
.menu__link {
opacity: 0; transform: translateY(-6px);
transition: opacity $duration $ease-spring, transform $duration $ease-spring;
transition-delay: calc(var(--i) * #{$stagger});
}
.stage.is-open .menu__link { opacity: 1; transform: translateY(0); }
08アイコンから円形に広がる
動きの内容
ボタンの位置から円が広がって画面を覆い、メニューが現れます。

<main class="stage is-demo">
<button class="burger" type="button" aria-label="メニューを開く">
<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="#">ホーム</a>
<a class="reveal__link" href="#">会社概要</a>
<a class="reveal__link" href="#">製品</a>
<a class="reveal__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · アイコンから円形に広がる — 計測 累積面積 99.997% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使う場面
動かしているのはclip-path circle()の半径だけで、widthやheightに触れないためレイアウトの再計算は起きません。
$duration: 450ms; // 円が画面を覆う時間
// アイコンの位置(右上)を起点に、clip-path の半径だけを動かす
.reveal {
position: absolute; inset: 0; z-index: 2;
background: $stage-ink;
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); }
09背景ぼかし+暗転
どんな動きか
メニューが開くと背後の.pageがfilter: blur()とbrightness()で同時にぼやけて暗くなり、視線がメニューに集まります。

<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="メニューを開く">
<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="#">ホーム</a>
<a class="menu__link" href="#">会社概要</a>
<a class="menu__link" href="#">製品</a>
<a class="menu__link" href="#">お問い合わせ</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 ? 'メニューを閉じる' : 'メニューを開く');
});
})();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 · 背景ぼかし+暗転 — 計測 累積面積 20.247% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
使いどころ
半透明の重ね要素を別に用意せず、.page自体に直接filterをかけているのがこの実装の特徴です。
$blur: 6px; // 背景がぼける強さ
$dim: .4; // 背景が暗くなる強さ(0〜1)
.page {
transition: filter $dur-base $ease-spring;
}
.stage.is-open .page { filter: blur($blur) brightness(1 - $dim); }
どこで崩れるか — 落とし穴
z-index の順番
css ドロワー メニューに近い05と08で最初につまずきました。オーバーレイやreveal要素にz-index: 2を、ボタンにはz-index: 3を割り当てないと、メニューが開いた瞬間にボタンがパネルの下に隠れ、二度とクリックで閉じられなくなります。閉じる入り口を潰さないためのz-index管理は、背景を覆う系の項目に共通する落とし穴でした。
blur と backdrop-filter
09は背後のページ全体にfilter: blur()をかけていますが、半透明パネルだけをぼかしたい場面ではbackdrop-filterのほうが向きます。backdrop-filterはChrome 76以降、Safariは18以降で接頭辞なしに対応し、Firefoxは103以降で使えます(MDNのbackdrop-filter)。もう一つ、06のようにJavaScriptを0行に保ちたいときは、popoverの代わりにチェックボックスと:has()で状態を拾う古い手も残っていますが、:has()はChrome 105以降、Safari 15.4以降、Firefox 121以降での対応です(MDNの:has())。対応が新しい分、素直に書けるのはpopoverのほうでした。9つとも直したファイルを開ける鍵は 2cpj9dyd という一続きの文字列で、下のボタンから zip を受け取ってください。
07のリンクが順番に出てくる遅延の考え方は、メニューの外でも使えます。同じ計算を縦に長く並べたものがタイムライン デザイン css 9選に段階ごとにまとめてあります。
アクセシビリティ
動きを減らす設定のとき
9つとも prefers-reduced-motion: reduce では、グリッドとデモが繰り返すループ演出だけを止め、transitionを1msに縮めます。クリックによる本来の開閉自体は今までどおり働くので、動きを止めても機能は失われません。ボタンのaria-labelは開閉のたびに「メニューを開く」「メニューを閉じる」に書き換わり、06のpopoverだけはEscキーでの閉じ方やフォーカスの戻し方をブラウザが標準で面倒を見てくれます。
このほかのCSSアニメーションはCSSアニメーションの分類に、このサイトの成り立ちはこのサイトについてにまとめています。
FAQ
9つを1つのサイトにまとめて使っても大丈夫ですか?
構いません。それぞれtransform-originやz-indexの取り方が独立しているので、ページごとに違う項目を使い分けても衝突しません。ただし同じヘッダーの中で2種類の開閉ボタンを並べると、訪問者がどちらが本物のメニューか迷うので、1ページには1種類に絞るのがおすすめです。
Reactではクラスの付け外しの代わりに何を使いますか?
zipのreact/フォルダのコンポーネントはuseStateの真偽値ひとつでクラス名を切り替えるだけで、01〜05・07〜09はそれで完結します。06だけはpopovertarget属性の代わりにrefを持たせ、showPopover()とhidePopover()を呼ぶ形にしてあります。
ドロワー メニュー css のみでもスマホで同じように開閉しますか?
はい、popoverもtransformもHTMLとCSSの標準機能なので、iOSのSafariでもAndroidのChromeでも同じように動きます。対応状況の詳細はMDNのPopover APIのページで確認できます。 なお画面が広いときは、同じナビゲーションでもハンバーガーより開いて広がる形が自然になります。その形はCSS ドロップダウン メニュー アニメーション 9選に別途まとめました。









