06Menu không cần JS với Popover API
Chỉ cần thuộc tính popover là menu mở ra, nhấn ra ngoài tự đóng.
Dùng choTrang tĩnh cần gắn menu mà không dùng JS
popoveranchor자동 닫힘

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


