06Popover API로 JS 0줄 메뉴
popover 속성만으로 메뉴가 열리고 바깥을 클릭하면 저절로 닫힌다.
쓰임JS 없이 메뉴를 붙여야 하는 정적 사이트
popoveranchor자동 닫힘

<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로 JS 0줄 메뉴 — 측정 누적면적 12.977% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · CSS 햄버거 메뉴 애니메이션 9종 — JS 없이, CSS만으로
이 데모가 속한 글
CSS 햄버거 메뉴 애니메이션 9종 — JS 없이, CSS만으로CSS 햄버거 메뉴 애니메이션은 클릭 한 번으로 세 줄 아이콘이 X로 바뀌고 메뉴가 열리는 전환 효과입니다. 아홉 개를 모았고 전부 transform과 opacity만 움직이며, JS 없이 CSS만으로 돌아갑니다. 이 글의 데모 전부 보기


