CSS 드롭다운 메뉴 애니메이션 9종 — JS 없이, CSS만으로
CSS 드롭다운 메뉴 애니메이션은 트리거를 누르거나 올렸을 때 패널이 어떻게 나타나고 사라지는지를 정하는 짧은 전환입니다. 아홉 개 중 하나(08)는 자바스크립트를 한 줄도 안 씁니다.
01 dropdown-menu-height-auto
02 dropdown-menu-fade-slide
03 dropdown-menu-scale-origin
04 dropdown-menu-clip-open
05 dropdown-menu-stagger-items
06 dropdown-menu-mega-menu-fill
07 dropdown-menu-hover-intent-delay
08 dropdown-menu-popover-native
09 dropdown-menu-mobile-full-sheet
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 height:auto 트랜지션
- 02 페이드+슬라이드
- 03 모서리 기준 확대
- 04 클립패스 오픈
- 05 항목 순차 등장
- 06 메가메뉴 배경 채우기
- 07 지연 열림(transition-delay)
- 08 popover 속성 사용
- 09 모바일 전체 시트
아홉 개는 흔히 보는 순서가 아니라 메뉴가 실제로 붙는 자리 순서로 놓았습니다. 일반 내비게이션(01~05), 넓은 카테고리 메뉴(06), 헤더에 여러 개가 나란한 경우(07), 가벼운 액션 메뉴(08), 좁은 화면(09)입니다. 아래 코드는 여닫는 핵심 규칙만 뽑았고, 트리거·마크업까지 다 붙은 파일은 zip 안에 있습니다. 세로로 항목을 늘어뜨리는 쪽이라면 CSS 아코디언 애니메이션이 01의 그리드 트릭을 더 깊게 파고, 클릭 뒤 떠서 사라질 알림이 필요하면 웹 토스트 알림 CSS가 그 역할입니다.
01height:auto 트랜지션
화면에서 일어나는 일
height: auto는 애니메이션할 수 없다는 게 CSS의 오래된 제약입니다.

<main class="stage">
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false">메뉴</button>
<div class="menu__panel">
<ul class="menu__list">
<li><a href="#">공지사항</a></li>
<li><a href="#">자주 묻는 질문</a></li>
<li><a href="#">고객센터</a></li>
</ul>
</div>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 패널이 늘어나는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $subject-blue-deep; // 트리거 버튼 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-ink);
.menu {
position: relative;
width: min(220px, 90vw);
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
width: 100%;
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
}
&__panel {
display: grid;
grid-template-rows: 0fr; // height:auto 를 흉내내는 트릭. 0fr↔1fr 만 움직인다
transition: grid-template-rows $duration $easing;
margin-top: 8px;
}
&__list {
overflow: hidden; // grid-template-rows 트릭엔 자식에 overflow:hidden 이 필수
min-height: 0;
margin: 0;
padding: 0;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
}
&__list li a {
display: block;
padding: clamp(8px, 2.6vw, 12px) clamp(12px, 3.6vw, 18px);
color: $stage-ink;
text-decoration: none;
border-bottom: 1px solid rgba($stage-ink, .08);
}
&__list li:last-child a { border-bottom: 0; }
&.is-demo &__panel { animation: reveal-loop $dur-loop $easing infinite; }
&.is-open &__panel { animation: none; grid-template-rows: 1fr; }
}
@keyframes reveal-loop {
0%, 30% { grid-template-rows: 0fr; }
45%, 80% { grid-template-rows: 1fr; }
95%, 100% { grid-template-rows: 0fr; }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none; }
.menu.is-demo .menu__panel { animation: none; grid-template-rows: 0fr; }
}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);
}
.menu {
position: relative;
width: min(220px, 90vw);
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
width: 100%;
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
cursor: pointer;
}
.menu__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms ease-out;
margin-top: 8px;
}
.menu__list {
overflow: hidden;
min-height: 0;
margin: 0;
padding: 0;
list-style: none;
background: #fff7e6;
border-radius: 16px;
}
.menu__list li a {
display: block;
padding: clamp(8px, 2.6vw, 12px) clamp(12px, 3.6vw, 18px);
color: #17141a;
text-decoration: none;
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.menu__list li:last-child a {
border-bottom: 0;
}
.menu.is-demo .menu__panel {
animation: reveal-loop 2s ease-out infinite;
}
.menu.is-open .menu__panel {
animation: none;
grid-template-rows: 1fr;
}
@keyframes reveal-loop {
0%, 30% {
grid-template-rows: 0fr;
}
45%, 80% {
grid-template-rows: 1fr;
}
95%, 100% {
grid-template-rows: 0fr;
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel {
transition: none;
}
.menu.is-demo .menu__panel {
animation: none;
grid-template-rows: 0fr;
}
}// 실제 클릭으로도 열고 닫힌다. 데모(.is-demo)는 스스로 반복하다가 사람이 누르면 멈추고 수동 토글로 바뀐다.
var m = document.querySelector('.menu');
var t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
var open = m.classList.toggle('is-open');
t.setAttribute('aria-expanded', open);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HeightAutoDropdown.module.scss";
export type HeightAutoDropdownProps = {
triggerLabel: string;
items: string[];
/** 패널이 늘어나는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function HeightAutoDropdown({
triggerLabel,
items,
duration = 300,
easing = "ease-out",
color = "#0a4fe8",
}: HeightAutoDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={styles.menu} style={vars}>
<button
type="button"
className={styles.trigger}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{triggerLabel}
</button>
<div className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
<ul className={styles.list}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</div>
</nav>
);
}01 · height:auto 트랜지션 — 측정 누적면적 29.538% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
css 드롭다운 메뉴 부드럽게 여는 법
대신 grid-template-rows를 0fr에서 1fr로 트랜지션하면 실제 콘텐츠 높이까지 부드럽게 늘어나는 것처럼 보입니다. 자식 요소에 overflow: hidden을 걸어야 늘어나는 중간 과정이 잘려 보이지 않습니다.
.menu__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.menu__list {
overflow: hidden; // grid-template-rows 트릭엔 필수
min-height: 0;
}
.menu.is-open .menu__panel { grid-template-rows: 1fr; }
02페이드+슬라이드
어떤 움직임인가
메뉴가 투명도와 동시에 위쪽에서 8px 미끄러져 내려오며 나타납니다.

<main class="stage">
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false">내 계정</button>
<ul class="menu__panel">
<li><a href="#">프로필</a></li>
<li><a href="#">설정</a></li>
<li><a href="#">로그아웃</a></li>
</ul>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 페이드+슬라이드 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $stage-ink; // 트리거 버튼 색 (노란 무대와 대비)
@include stage($stage-yellow);
.menu {
position: relative;
align-self: flex-start; // 패널이 트리거 아래로 열려 무대 중앙에 두면 320px 에서 아래로 넘친다 — 위로 붙여 여유를 둔다 (실측 320px 재검증 2026-09-10)
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
}
&__panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: max-content;
max-width: min(180px, 78vw);
margin: 0;
padding: 6px;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-8px); // transform · opacity 만 움직인다
pointer-events: none;
}
&__panel li a {
display: block;
padding: 8px 12px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
}
&__panel li a:hover { background: rgba($stage-ink, .06); }
&.is-open &__panel { opacity: 1; transform: translateY(0); pointer-events: auto; transition: opacity $duration $easing, transform $duration $easing; }
&.is-demo &__panel { animation: reveal-loop $dur-loop $easing infinite; }
}
@keyframes reveal-loop {
0%, 30% { opacity: 0; transform: translateY(-8px); }
45%, 80% { opacity: 1; transform: translateY(0); }
95%, 100% { opacity: 0; transform: translateY(-8px); }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none !important; }
.menu.is-demo .menu__panel { animation: none; opacity: 0; transform: none; }
}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);
}
.menu {
position: relative;
align-self: flex-start;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.menu__panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: max-content;
max-width: min(180px, 78vw);
margin: 0;
padding: 6px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
.menu__panel li a {
display: block;
padding: 8px 12px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
}
.menu__panel li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu.is-open .menu__panel {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.menu.is-demo .menu__panel {
animation: reveal-loop 2s ease-out infinite;
}
@keyframes reveal-loop {
0%, 30% {
opacity: 0;
transform: translateY(-8px);
}
45%, 80% {
opacity: 1;
transform: translateY(0);
}
95%, 100% {
opacity: 0;
transform: translateY(-8px);
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel {
transition: none !important;
}
.menu.is-demo .menu__panel {
animation: none;
opacity: 0;
transform: none;
}
}// 트리거 클릭으로 열고, 메뉴 바깥을 클릭하면 닫힌다. 데모(.is-demo)는 스스로 반복한다.
var m = document.querySelector('.menu'), t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
t.setAttribute('aria-expanded', m.classList.toggle('is-open'));
});
document.addEventListener('pointerdown', function (e) {
if (!m.contains(e.target)) { m.classList.remove('is-open'); t.setAttribute('aria-expanded', false); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FadeSlideDropdown.module.scss";
export type FadeSlideDropdownProps = {
triggerLabel: string;
items: string[];
/** 페이드+슬라이드 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function FadeSlideDropdown({
triggerLabel,
items,
duration = 220,
easing = "ease-out",
color = "#17141a",
}: FadeSlideDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDown(e: PointerEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, []);
return (
<nav className={styles.menu} style={vars} ref={ref}>
<button type="button" className={styles.trigger} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
<ul className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</nav>
);
}02 · 페이드+슬라이드 — 측정 누적면적 11.872% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
헤더의 계정 메뉴처럼 클릭 한 번으로 여닫히는 자리에 씁니다. 메뉴 바깥을 클릭하면 닫히는 리스너 하나면 충분합니다.
.menu__panel {
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
}
.menu.is-open .menu__panel {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
transition: opacity $duration $easing, transform $duration $easing;
}
03모서리 기준 확대
무엇이 보이나
메뉴가 트리거 버튼이 붙은 모서리를 transform-origin으로 삼아 그 지점에서부터 확대되며 열립니다.

<main class="stage">
<div class="toolbar">
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false">프로필</button>
<ul class="menu__panel">
<li><a href="#">계정 설정</a></li>
<li><a href="#">알림</a></li>
<li><a href="#">로그아웃</a></li>
</ul>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 확대되는 시간
$easing: $ease-pop; // 살짝 튀는 이징
$color: $subject-cream; // 트리거 버튼 색 (주황 무대와 대비)
@include stage($stage-orange);
.toolbar {
width: min(280px, 92vw);
display: flex;
justify-content: flex-end;
align-self: flex-start; // 패널이 트리거 아래로 열려 무대 중앙에 두면 320px 에서 아래로 넘친다 — 위로 붙여 여유를 둔다 (실측 320px 재검증 2026-09-10)
}
.menu {
position: relative;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
border: 0;
border-radius: $r-pill;
padding: clamp(9px, 3vw, 12px) clamp(16px, 4vw, 20px);
font-weight: 700;
font-family: inherit;
color: $stage-ink;
background: $color;
cursor: pointer;
}
&__panel {
position: absolute;
top: calc(100% + 8px);
right: 0; // 트리거가 붙은 모서리
width: max-content;
max-width: min(170px, 76vw);
margin: 0;
padding: 6px;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform-origin: top right; // 트리거 모서리를 기준으로 확대
transform: scale(.9);
opacity: 0;
pointer-events: none;
}
&__panel li a {
display: block;
padding: 8px 12px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
}
&__panel li a:hover { background: rgba($stage-ink, .06); }
&.is-open &__panel { opacity: 1; transform: scale(1); pointer-events: auto; transition: opacity $duration $easing, transform $duration $easing; }
&.is-demo &__panel { animation: reveal-loop $dur-loop $easing infinite; }
}
@keyframes reveal-loop {
0%, 30% { opacity: 0; transform: scale(.9); }
45%, 80% { opacity: 1; transform: scale(1); }
95%, 100% { opacity: 0; transform: scale(.9); }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none !important; }
.menu.is-demo .menu__panel { animation: none; opacity: 0; transform: scale(.9); }
}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);
}
.toolbar {
width: min(280px, 92vw);
display: flex;
justify-content: flex-end;
align-self: flex-start;
}
.menu {
position: relative;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 999px;
padding: clamp(9px, 3vw, 12px) clamp(16px, 4vw, 20px);
font-weight: 700;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
}
.menu__panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: max-content;
max-width: min(170px, 76vw);
margin: 0;
padding: 6px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top right;
transform: scale(0.9);
opacity: 0;
pointer-events: none;
}
.menu__panel li a {
display: block;
padding: 8px 12px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
}
.menu__panel li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu.is-open .menu__panel {
opacity: 1;
transform: scale(1);
pointer-events: auto;
transition: opacity 220ms cubic-bezier(0.2, 1.4, 0.4, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.menu.is-demo .menu__panel {
animation: reveal-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes reveal-loop {
0%, 30% {
opacity: 0;
transform: scale(0.9);
}
45%, 80% {
opacity: 1;
transform: scale(1);
}
95%, 100% {
opacity: 0;
transform: scale(0.9);
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel {
transition: none !important;
}
.menu.is-demo .menu__panel {
animation: none;
opacity: 0;
transform: scale(0.9);
}
}// 트리거를 클릭하면 열리고 닫힌다. 데모(.is-demo)는 스스로 반복한다.
var m = document.querySelector('.menu'), t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
t.setAttribute('aria-expanded', m.classList.toggle('is-open'));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScaleOriginDropdown.module.scss";
export type ScaleOriginDropdownProps = {
triggerLabel: string;
items: string[];
/** 확대되는 시간 (기본 220ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ScaleOriginDropdown({
triggerLabel,
items,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: ScaleOriginDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={styles.menu} style={vars}>
<button type="button" className={styles.trigger} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
<ul className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</nav>
);
}03 · 모서리 기준 확대 — 측정 누적면적 12.244% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
우측 상단 프로필 메뉴처럼 화면 끝에 붙는 메뉴에 잘 맞습니다.
.menu__panel {
transform-origin: top right; // 트리거가 붙은 모서리
transform: scale(.9);
opacity: 0;
}
.menu.is-open .menu__panel {
transform: scale(1);
opacity: 1;
transition: transform $duration $easing, opacity $duration $easing;
}
04클립패스 오픈
화면에서 일어나는 일
clip-path 사각형이 트리거 아래쪽에서부터 펼쳐지며 메뉴 내용을 잘라낸 채로 드러냅니다.

<main class="stage">
<div class="card">
<span class="card__title">문서 제목</span>
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false" aria-label="더보기">⋯</button>
<ul class="menu__panel">
<li><a href="#">수정</a></li>
<li><a href="#">복제</a></li>
<li><a href="#">삭제</a></li>
</ul>
</nav>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 클립이 펼쳐지는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $subject-blue; // 문서 카드 강조 색
@include stage($stage-paper);
.card {
position: relative;
align-self: flex-start; // 패널이 트리거 아래로 열려 무대 중앙에 두면 320px 에서 아래로 넘친다 — 위로 붙여 여유를 둔다 (실측 320px 재검증 2026-09-10)
width: min(240px, 88vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-press;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.2;
font-family: inherit;
color: $stage-ink;
border-left: 4px solid $color;
}
.menu {
position: relative;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
border: 0;
border-radius: $r-control;
width: 32px;
height: 32px;
font-weight: 700;
font-size: 18px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
background: transparent;
cursor: pointer;
}
&__trigger:hover { background: rgba($stage-ink, .06); }
&__panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: max-content;
max-width: min(140px, 70vw);
margin: 0;
padding: 6px;
list-style: none;
background: #fff;
border-radius: $r-control;
box-shadow: $shadow-raised;
clip-path: inset(0 0 100% 0); // 위에서부터 잘려 보이지 않던 것이 펼쳐진다
pointer-events: none;
}
&__panel li a {
display: block;
padding: 8px 10px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
}
&__panel li a:hover { background: rgba($stage-ink, .06); }
&.is-open &__panel { clip-path: inset(0 0 0% 0); pointer-events: auto; transition: clip-path $duration $easing; }
&.is-demo &__panel { animation: reveal-loop $dur-loop $easing infinite; }
}
@keyframes reveal-loop {
0%, 30% { clip-path: inset(0 0 100% 0); }
45%, 80% { clip-path: inset(0 0 0% 0); }
95%, 100% { clip-path: inset(0 0 100% 0); }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none !important; }
.menu.is-demo .menu__panel { animation: none; clip-path: inset(0 0 100% 0); }
}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);
}
.card {
position: relative;
align-self: flex-start;
width: min(240px, 88vw);
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
font-weight: 600;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.2;
font-family: inherit;
color: #17141a;
border-left: 4px solid #2f6df6;
}
.menu {
position: relative;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 12px;
width: 32px;
height: 32px;
font-weight: 700;
font-size: 18px;
line-height: 1;
font-family: inherit;
color: #17141a;
background: transparent;
cursor: pointer;
}
.menu__trigger:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu__panel {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: max-content;
max-width: min(140px, 70vw);
margin: 0;
padding: 6px;
list-style: none;
background: #fff;
border-radius: 12px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
clip-path: inset(0 0 100% 0);
pointer-events: none;
}
.menu__panel li a {
display: block;
padding: 8px 10px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
}
.menu__panel li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu.is-open .menu__panel {
clip-path: inset(0 0 0% 0);
pointer-events: auto;
transition: clip-path 300ms ease-out;
}
.menu.is-demo .menu__panel {
animation: reveal-loop 2s ease-out infinite;
}
@keyframes reveal-loop {
0%, 30% {
clip-path: inset(0 0 100% 0);
}
45%, 80% {
clip-path: inset(0 0 0% 0);
}
95%, 100% {
clip-path: inset(0 0 100% 0);
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel {
transition: none !important;
}
.menu.is-demo .menu__panel {
animation: none;
clip-path: inset(0 0 100% 0);
}
}// 트리거를 클릭하면 열리고 닫힌다. 데모(.is-demo)는 스스로 반복한다.
var m = document.querySelector('.menu'), t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
t.setAttribute('aria-expanded', m.classList.toggle('is-open'));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ClipOpenDropdown.module.scss";
export type ClipOpenDropdownProps = {
title: string;
moreLabel: string;
items: string[];
/** 클립이 펼쳐지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function ClipOpenDropdown({
title,
moreLabel,
items,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: ClipOpenDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.card} style={vars}>
<span className={styles.title}>{title}</span>
<nav className={styles.menu}>
<button type="button" className={styles.trigger} aria-expanded={open} aria-label={moreLabel} onClick={() => setOpen((v) => !v)}>
⋯
</button>
<ul className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</nav>
</div>
);
}04 · 클립패스 오픈 — 측정 누적면적 1.538% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
카드 안 컨텍스트 메뉴처럼 좁은 자리에 어울립니다.
.menu__panel {
clip-path: inset(0 0 100% 0);
pointer-events: none;
}
.menu.is-open .menu__panel {
clip-path: inset(0 0 0% 0);
pointer-events: auto;
transition: clip-path $duration $easing;
}
05항목 순차 등장
어떤 움직임인가
메뉴가 열리면 항목들이 한꺼번에 나타나지 않고 위에서부터 시차를 두고 하나씩 등장합니다.

<main class="stage">
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false">설정</button>
<ul class="menu__panel">
<li><a href="#">일반</a></li>
<li><a href="#">보안</a></li>
<li><a href="#">알림</a></li>
<li><a href="#">결제</a></li>
</ul>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 항목 하나가 나타나는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$stagger: 20ms; // 항목 사이 시차
$color: $subject-blue-deep; // 트리거 버튼 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-ink);
.menu {
position: relative;
align-self: flex-start; // 패널이 4개 항목만큼 길어 트리거를 무대 중앙에 두면 아래로 넘친다 — 위로 붙여 여유를 둔다 (실측 320px 재검증 2026-09-10)
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
}
&__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: max-content;
max-width: min(160px, 78vw);
margin: 0;
padding: 6px;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
visibility: hidden;
}
&.is-open &__panel,
&.is-demo &__panel { visibility: visible; }
&__panel li a {
display: block;
padding: 6px 12px; // 320px 재검증: align-self:flex-start 로도 4항목분 높이가 8px 모자랐다 — 항목당 4px(2px×2) 씩 줄여 20px 확보 (실측 2026-09-10)
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
}
&__panel li a:hover { background: rgba($stage-ink, .06); }
// 항목마다 20ms 씩 늦게 등장 — transform · opacity 만 움직인다
&__panel li { opacity: 0; transform: translateY(-6px); }
&.is-open &__panel li { opacity: 1; transform: none; transition: opacity $duration $easing, transform $duration $easing; }
&.is-demo &__panel li { animation: item-loop $dur-loop $easing infinite; }
@for $i from 1 through 4 {
&.is-open &__panel li:nth-child(#{$i}) { transition-delay: ($i - 1) * $stagger; }
&.is-demo &__panel li:nth-child(#{$i}) { animation-delay: ($i - 1) * $stagger; }
}
}
@keyframes item-loop {
0%, 30% { opacity: 0; transform: translateY(-6px); }
40%, 78% { opacity: 1; transform: none; }
88%, 100% { opacity: 0; transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel li { transition: none !important; }
.menu.is-demo .menu__panel li { animation: none; opacity: 0; transform: none; }
.menu.is-demo .menu__panel { visibility: hidden; }
}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);
}
.menu {
position: relative;
align-self: flex-start;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
cursor: pointer;
}
.menu__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: max-content;
max-width: min(160px, 78vw);
margin: 0;
padding: 6px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
visibility: hidden;
}
.menu.is-open .menu__panel, .menu.is-demo .menu__panel {
visibility: visible;
}
.menu__panel li a {
display: block;
padding: 6px 12px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
}
.menu__panel li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu__panel li {
opacity: 0;
transform: translateY(-6px);
}
.menu.is-open .menu__panel li {
opacity: 1;
transform: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.menu.is-demo .menu__panel li {
animation: item-loop 2s ease-out infinite;
}
.menu.is-open .menu__panel li:nth-child(1) {
transition-delay: 0ms;
}
.menu.is-demo .menu__panel li:nth-child(1) {
animation-delay: 0ms;
}
.menu.is-open .menu__panel li:nth-child(2) {
transition-delay: 20ms;
}
.menu.is-demo .menu__panel li:nth-child(2) {
animation-delay: 20ms;
}
.menu.is-open .menu__panel li:nth-child(3) {
transition-delay: 40ms;
}
.menu.is-demo .menu__panel li:nth-child(3) {
animation-delay: 40ms;
}
.menu.is-open .menu__panel li:nth-child(4) {
transition-delay: 60ms;
}
.menu.is-demo .menu__panel li:nth-child(4) {
animation-delay: 60ms;
}
@keyframes item-loop {
0%, 30% {
opacity: 0;
transform: translateY(-6px);
}
40%, 78% {
opacity: 1;
transform: none;
}
88%, 100% {
opacity: 0;
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel li {
transition: none !important;
}
.menu.is-demo .menu__panel li {
animation: none;
opacity: 0;
transform: none;
}
.menu.is-demo .menu__panel {
visibility: hidden;
}
}// 트리거를 클릭하면 열리고 닫힌다. 데모(.is-demo)는 스스로 반복한다.
var m = document.querySelector('.menu'), t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
t.setAttribute('aria-expanded', m.classList.toggle('is-open'));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StaggerItemsDropdown.module.scss";
export type StaggerItemsDropdownProps = {
triggerLabel: string;
items: string[];
/** 항목 하나가 나타나는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 항목 사이 시차 ms (기본 20ms) */
stagger?: number;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function StaggerItemsDropdown({
triggerLabel,
items,
duration = 220,
easing = "ease-out",
stagger = 20,
color = "#0a4fe8",
}: StaggerItemsDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={styles.menu} style={vars}>
<button type="button" className={styles.trigger} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
<ul className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
{items.map((label, i) => (
<li key={label} style={{ transitionDelay: open ? `${i * stagger}ms` : "0ms" }}>
<a href="#">{label}</a>
</li>
))}
</ul>
</nav>
);
}05 · 항목 순차 등장 — 측정 누적면적 1.174% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
항목이 많은 설정 메뉴에서 한눈에 다 쏟아지는 느낌을 줄여줍니다.
.menu__panel li {
opacity: 0;
transform: translateY(-6px);
}
.menu.is-open .menu__panel li {
opacity: 1;
transform: none;
transition: opacity $duration $easing, transform $duration $easing;
}
@for $i from 1 through 4 {
.menu.is-open .menu__panel li:nth-child(#{$i}) {
transition-delay: ($i - 1) * $stagger;
}
}
06메가메뉴 배경 채우기
무엇이 보이나
메가메뉴 배경 패널이 항목 폭에 맞춰 옆으로 번지듯 채워지며 다단 카테고리를 한 판에 펼칩니다.

<main class="stage">
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false">카테고리</button>
<div class="menu__panel">
<div class="menu__fill"></div>
<div class="menu__grid">
<a href="#">여성</a>
<a href="#">남성</a>
<a href="#">키즈</a>
<a href="#">홈</a>
</div>
</div>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 배경이 번지는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $stage-ink; // 트리거 버튼 색 (노란 무대와 대비)
@include stage($stage-yellow);
.menu {
position: relative;
align-self: flex-start; // 패널이 트리거 아래로 열려 무대 중앙에 두면 320px 에서 아래로 넘친다 — 위로 붙여 여유를 둔다 (실측 320px 재검증 2026-09-10)
justify-self: start; // 패널이 260px 까지 오른쪽으로 늘어나는데 트리거가 무대 가운데라 320px 에서 오른쪽으로도 넘친다 — 왼쪽에 붙인다
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
}
&__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(260px, 90vw);
padding: 4px;
pointer-events: none;
}
// 배경이 왼쪽부터 옆으로 번지듯 채워진다 (transform 만 움직인다)
&__fill {
position: absolute;
inset: 0;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
transform: scaleX(0);
transform-origin: left; // 규칙8: 왼쪽부터 번짐
}
&__grid {
position: relative;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 8px;
opacity: 0;
}
&__grid a {
padding: 8px 10px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
text-align: center;
}
&__grid a:hover { background: rgba($stage-ink, .06); }
&.is-open &__fill { transform: scaleX(1); pointer-events: auto; transition: transform $duration $easing; }
&.is-open &__grid { opacity: 1; pointer-events: auto; transition: opacity $duration $easing $duration * .4; }
&.is-demo &__fill { animation: fill-loop $dur-loop $easing infinite; }
&.is-demo &__grid { animation: grid-loop $dur-loop $easing infinite; }
}
@keyframes fill-loop {
0%, 25% { transform: scaleX(0); }
45%, 80% { transform: scaleX(1); }
95%, 100% { transform: scaleX(0); }
}
@keyframes grid-loop {
0%, 33% { opacity: 0; }
50%, 78% { opacity: 1; }
92%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.menu__fill, .menu__grid { transition: none !important; }
.menu.is-demo .menu__fill, .menu.is-demo .menu__grid { animation: none; }
.menu.is-demo .menu__fill { transform: scaleX(0); }
.menu.is-demo .menu__grid { opacity: 0; }
}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);
}
.menu {
position: relative;
align-self: flex-start;
justify-self: start;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: #fff7e6;
background: #17141a;
cursor: pointer;
}
.menu__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: min(260px, 90vw);
padding: 4px;
pointer-events: none;
}
.menu__fill {
position: absolute;
inset: 0;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scaleX(0);
transform-origin: left;
}
.menu__grid {
position: relative;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 8px;
opacity: 0;
}
.menu__grid a {
padding: 8px 10px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
text-align: center;
}
.menu__grid a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu.is-open .menu__fill {
transform: scaleX(1);
pointer-events: auto;
transition: transform 300ms ease-out;
}
.menu.is-open .menu__grid {
opacity: 1;
pointer-events: auto;
transition: opacity 300ms ease-out 120ms;
}
.menu.is-demo .menu__fill {
animation: fill-loop 2s ease-out infinite;
}
.menu.is-demo .menu__grid {
animation: grid-loop 2s ease-out infinite;
}
@keyframes fill-loop {
0%, 25% {
transform: scaleX(0);
}
45%, 80% {
transform: scaleX(1);
}
95%, 100% {
transform: scaleX(0);
}
}
@keyframes grid-loop {
0%, 33% {
opacity: 0;
}
50%, 78% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.menu__fill, .menu__grid {
transition: none !important;
}
.menu.is-demo .menu__fill, .menu.is-demo .menu__grid {
animation: none;
}
.menu.is-demo .menu__fill {
transform: scaleX(0);
}
.menu.is-demo .menu__grid {
opacity: 0;
}
}// 트리거를 클릭하면 열리고 닫힌다. 데모(.is-demo)는 스스로 반복한다.
var m = document.querySelector('.menu'), t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
t.setAttribute('aria-expanded', m.classList.toggle('is-open'));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MegaMenuFillDropdown.module.scss";
export type MegaMenuFillDropdownProps = {
triggerLabel: string;
categories: string[];
/** 배경이 번지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MegaMenuFillDropdown({
triggerLabel,
categories,
duration = 300,
easing = "ease-out",
color = "#17141a",
}: MegaMenuFillDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<nav className={styles.menu} style={vars}>
<button type="button" className={styles.trigger} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
<div className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
<div className={styles.fill} />
<div className={styles.grid}>
{categories.map((label) => (
<a key={label} href="#">{label}</a>
))}
</div>
</div>
</nav>
);
}06 · 메가메뉴 배경 채우기 — 측정 누적면적 25.75% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
쇼핑몰 헤더의 메가 메뉴 ui처럼 카테고리가 많을 때 씁니다.
.menu__fill {
position: absolute;
inset: 0;
transform: scaleX(0);
transform-origin: left;
}
.menu.is-open .menu__fill {
transform: scaleX(1);
transition: transform $duration $easing;
}
.menu__grid { opacity: 0; }
.menu.is-open .menu__grid {
opacity: 1;
transition: opacity $duration $easing $duration * .4;
}
07지연 열림(transition-delay)
화면에서 일어나는 일
마우스가 지나가기만 해도 안 열리게 transition-delay로 진입 후 150ms를 기다렸다가 메뉴를 엽니다.

<main class="stage">
<nav class="menu is-demo">
<button class="menu__trigger" type="button" aria-expanded="false">제품</button>
<ul class="menu__panel">
<li><a href="#">기능</a></li>
<li><a href="#">가격</a></li>
<li><a href="#">고객사</a></li>
</ul>
</nav>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 열리고 닫히는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$delay: 150ms; // 진입 후 기다리는 시간 (닫힐 땐 지연 없음)
$color: $subject-cream; // 트리거 버튼 색 (주황 무대와 대비)
@include stage($stage-orange);
.menu {
position: relative;
align-self: flex-start; // 패널이 트리거 아래로 열려 무대 중앙에 두면 아래로 넘친다 — 위로 붙여 여유를 둔다 (실측 320px 재검증 2026-09-10)
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
&__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: $stage-ink;
background: $color;
cursor: pointer;
}
&__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: max-content;
max-width: min(150px, 76vw);
margin: 0;
padding: 6px;
list-style: none;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-raised;
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity $duration $easing, transform $duration $easing; // 닫힐 때는 지연 없이 바로
}
&__panel li a {
display: block;
padding: 8px 12px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
}
&__panel li a:hover { background: rgba($stage-ink, .06); }
// 마우스가 지나가기만 해선 안 열리게 — 진입 후 $delay 를 기다렸다가 연다
&:hover &__panel,
&.is-open &__panel {
opacity: 1;
transform: none;
pointer-events: auto;
transition-delay: $delay;
}
&.is-demo &__panel { animation: reveal-loop $dur-loop $easing infinite; }
}
@keyframes reveal-loop {
0%, 40% { opacity: 0; transform: translateY(-6px); }
55%, 80% { opacity: 1; transform: none; }
95%, 100% { opacity: 0; transform: translateY(-6px); }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none !important; }
.menu.is-demo .menu__panel { animation: none; opacity: 0; transform: none; }
}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);
}
.menu {
position: relative;
align-self: flex-start;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: #17141a;
background: #fff7e6;
cursor: pointer;
}
.menu__panel {
position: absolute;
top: calc(100% + 8px);
left: 0;
width: max-content;
max-width: min(150px, 76vw);
margin: 0;
padding: 6px;
list-style: none;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition: opacity 220ms ease-out, transform 220ms ease-out;
}
.menu__panel li a {
display: block;
padding: 8px 12px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
}
.menu__panel li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu:hover .menu__panel, .menu.is-open .menu__panel {
opacity: 1;
transform: none;
pointer-events: auto;
transition-delay: 150ms;
}
.menu.is-demo .menu__panel {
animation: reveal-loop 2s ease-out infinite;
}
@keyframes reveal-loop {
0%, 40% {
opacity: 0;
transform: translateY(-6px);
}
55%, 80% {
opacity: 1;
transform: none;
}
95%, 100% {
opacity: 0;
transform: translateY(-6px);
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel {
transition: none !important;
}
.menu.is-demo .menu__panel {
animation: none;
opacity: 0;
transform: none;
}
}// 데스크톱은 :hover + transition-delay 로만 연다(아래 CSS). 터치 화면을 위해 클릭 토글도 둔다.
var m = document.querySelector('.menu'), t = m.querySelector('.menu__trigger');
t.addEventListener('click', function () {
m.classList.remove('is-demo');
t.setAttribute('aria-expanded', m.classList.toggle('is-open'));
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./HoverIntentDropdown.module.scss";
export type HoverIntentDropdownProps = {
triggerLabel: string;
items: string[];
/** 열리고 닫히는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 진입 후 기다리는 시간 (기본 150ms, 닫힐 땐 지연 없음) */
delay?: number;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--delay"?: string; "--color"?: string };
export default function HoverIntentDropdown({
triggerLabel,
items,
duration = 220,
easing = "ease-out",
delay = 150,
color = "#fff7e6",
}: HoverIntentDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--delay": `${delay}ms`, "--color": color };
return (
// 데스크톱은 CSS :hover 만으로 지연 열림이 동작한다. 터치 화면을 위해 클릭 토글도 겸한다.
<nav className={styles.menu} style={vars} onClick={() => setOpen((v) => !v)}>
<button type="button" className={styles.trigger} aria-expanded={open}>{triggerLabel}</button>
<ul className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</nav>
);
}07 · 지연 열림(transition-delay) — 측정 누적면적 10.278% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
쓰는 자리
헤더에 메뉴 여러 개가 나란할 때 커서가 스치기만 해도 전부 열려버리는 걸 막습니다.
.menu__panel {
opacity: 0;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing; // 닫힐 땐 지연 없음
}
.menu:hover .menu__panel {
opacity: 1;
transform: none;
transition-delay: $delay; // 열릴 때만 기다린다
}
08popover 속성 사용
어떤 움직임인가
popover 속성과 ::backdrop를 쓰면 JS 토글 없이 브라우저가 열고 닫기·바깥 클릭·포커스를 대신 관리합니다.

<main class="stage">
<div class="menu">
<button class="menu__trigger" type="button" popovertarget="menu-panel">옵션</button>
<ul class="menu__panel" id="menu-panel" popover>
<li><a href="#">공유</a></li>
<li><a href="#">북마크</a></li>
<li><a href="#">신고</a></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 내용이 접혔다 펴지는 시간
$easing: $ease-pop; // 살짝 튀는 이징
$color: $subject-blue-deep; // 트리거 버튼 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-paper);
.menu {
position: relative;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
}
.menu__panel {
width: max-content;
max-width: min(150px, 70vw);
margin: 0;
padding: 6px;
list-style: none;
border: 0;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-raised;
transform: scale(.9);
opacity: 0;
}
.menu__panel li a {
display: block;
padding: 8px 12px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
}
.menu__panel li a:hover { background: rgba($stage-ink, .06); }
.menu__panel::backdrop {
background: rgba($stage-ink, .18);
}
// popover 는 열어 둔 채, 안쪽 내용만 2초 주기로 접었다 편다 (transform · opacity 만 움직인다)
.menu__panel:popover-open { animation: pop-loop $dur-loop $easing infinite; }
@keyframes pop-loop {
0%, 30% { transform: scale(.9); opacity: 0; }
45%, 80% { transform: scale(1); opacity: 1; }
95%, 100% { transform: scale(.9); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none !important; }
.menu__panel:popover-open { animation: none; transform: scale(1); opacity: 1; }
}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);
}
.menu {
position: relative;
font-weight: 500;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1.4;
font-family: inherit;
}
.menu__trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
cursor: pointer;
}
.menu__panel {
width: max-content;
max-width: min(150px, 70vw);
margin: 0;
padding: 6px;
list-style: none;
border: 0;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: scale(0.9);
opacity: 0;
}
.menu__panel li a {
display: block;
padding: 8px 12px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
}
.menu__panel li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu__panel::backdrop {
background: rgba(23, 20, 26, 0.18);
}
.menu__panel:popover-open {
animation: pop-loop 2s cubic-bezier(0.2, 1.4, 0.4, 1) infinite;
}
@keyframes pop-loop {
0%, 30% {
transform: scale(0.9);
opacity: 0;
}
45%, 80% {
transform: scale(1);
opacity: 1;
}
95%, 100% {
transform: scale(0.9);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.menu__panel {
transition: none !important;
}
.menu__panel:popover-open {
animation: none;
transform: scale(1);
opacity: 1;
}
}// popover 는 열려야 화면에 그려진다. 열어 둔 채 안쪽 내용만 2초 주기로 접었다 편다.
// 트리거는 popovertarget 하나로 실제 열고 닫기·바깥 클릭·Esc 를 브라우저가 대신 처리한다.
document.getElementById('menu-panel').showPopover();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PopoverNativeDropdown.module.scss";
export type PopoverNativeDropdownProps = {
triggerLabel: string;
items: string[];
/** 내용이 접혔다 펴지는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function PopoverNativeDropdown({
triggerLabel,
items,
duration = 300,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: PopoverNativeDropdownProps) {
const [open, setOpen] = useState(false);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={styles.menu} style={vars}>
<button type="button" className={styles.trigger} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
{/* React 는 showPopover() 를 ref 로 직접 불러야 해서 상태로 대신한다. 실제 zip 의 vanilla 판은 popover 속성 + popovertarget 을 그대로 쓴다 */}
<ul className={open ? `${styles.panel} ${styles.panelOpen}` : styles.panel}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</div>
);
}08 · popover 속성 사용 — 측정 누적면적 8.969% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
언제 쓰나
트리거 버튼에 popovertarget만 걸면 됩니다.
<button popovertarget="menu-panel">옵션</button>
<ul id="menu-panel" popover>
<li><a href="#">공유</a></li>
<li><a href="#">북마크</a></li>
<li><a href="#">신고</a></li>
</ul>
.menu__panel {
transform: scale(.9);
opacity: 0;
}
.menu__panel::backdrop {
background: rgba($stage-ink, .18);
transition: background $duration $easing;
}
.menu__panel:popover-open {
transform: scale(1);
opacity: 1;
}
09모바일 전체 시트
무엇이 보이나
좁은 화면에서는 드롭다운 대신 메뉴가 화면 아래에서 전체 폭 시트로 올라와 손가락으로 누르기 쉬운 크기가 됩니다.

<main class="stage">
<button class="menu__trigger is-demo" type="button" aria-expanded="false">메뉴</button>
<ul class="menu__sheet">
<li><a href="#">홈</a></li>
<li><a href="#">검색</a></li>
<li><a href="#">마이페이지</a></li>
<li><a href="#">설정</a></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base; // 시트가 올라오는 시간
$easing: $ease-out; // 시작 빠르고 끝은 부드럽게
$color: $subject-blue-deep; // 트리거 버튼 색 (크림 글자가 얹히므로 진한 파랑)
@include stage($stage-ink);
.stage { position: relative; overflow: hidden; } // 시트의 기준 — 닫힌 시트는 translateY(100%) 로 화면 아래에 있다.
// 열렸을 땐 시트가 무대 안에 완전히 들어오므로(위 계산: top 112 / bottom 300, 무대는 0~300) 잘리지 않는다.
// overflow:hidden 이 없으면 닫힌 상태의 화면 밖 188px 가 body.scrollHeight 를 밀어 올려 320px 재검증에서 넘침으로 잡힌다
.menu__trigger {
border: 0;
border-radius: $r-control;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1;
font-family: inherit;
color: $subject-cream;
background: $color;
cursor: pointer;
}
.menu__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
list-style: none;
background: $subject-cream;
border-radius: $r-card $r-card 0 0;
box-shadow: $shadow-raised;
transform: translateY(100%); // 화면 아래에서 전체 폭으로 올라온다
}
.menu__sheet li a {
display: block;
padding: clamp(9px, 2.6vw, 12px) 12px;
border-radius: $r-control - 4px;
color: $stage-ink;
text-decoration: none;
text-align: center;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.2;
font-family: inherit;
}
.menu__sheet li a:hover { background: rgba($stage-ink, .06); }
.menu__sheet.is-open { transform: translateY(0); transition: transform $duration $easing; }
.menu__trigger.is-demo ~ .menu__sheet { animation: sheet-loop $dur-loop $easing infinite; }
@keyframes sheet-loop {
0%, 30% { transform: translateY(100%); }
45%, 80% { transform: translateY(0); }
95%, 100% { transform: translateY(100%); }
}
@media (prefers-reduced-motion: reduce) {
.menu__sheet { transition: none !important; }
.menu__trigger.is-demo ~ .menu__sheet { animation: none; transform: translateY(100%); }
}
// 320px 에서도 넘치지 않게 — 좁은 화면일수록 오히려 이 패턴이 맞는 크기다
@media (max-width: 360px) {
.menu__sheet { padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); }
}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;
}
.menu__trigger {
border: 0;
border-radius: 12px;
padding: clamp(9px, 3vw, 13px) clamp(16px, 4vw, 22px);
font-weight: 700;
font-size: clamp(13px, 3.6vw, 16px);
line-height: 1;
font-family: inherit;
color: #fff7e6;
background: #0a4fe8;
cursor: pointer;
}
.menu__sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
list-style: none;
background: #fff7e6;
border-radius: 16px 16px 0 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform: translateY(100%);
}
.menu__sheet li a {
display: block;
padding: clamp(9px, 2.6vw, 12px) 12px;
border-radius: 8px;
color: #17141a;
text-decoration: none;
text-align: center;
font-weight: 600;
font-size: clamp(13px, 3.6vw, 15px);
line-height: 1.2;
font-family: inherit;
}
.menu__sheet li a:hover {
background: rgba(23, 20, 26, 0.06);
}
.menu__sheet.is-open {
transform: translateY(0);
transition: transform 300ms ease-out;
}
.menu__trigger.is-demo ~ .menu__sheet {
animation: sheet-loop 2s ease-out infinite;
}
@keyframes sheet-loop {
0%, 30% {
transform: translateY(100%);
}
45%, 80% {
transform: translateY(0);
}
95%, 100% {
transform: translateY(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.menu__sheet {
transition: none !important;
}
.menu__trigger.is-demo ~ .menu__sheet {
animation: none;
transform: translateY(100%);
}
}
@media (max-width: 360px) {
.menu__sheet {
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
}// 트리거를 클릭하면 열리고, 시트 바깥을 클릭하면 닫힌다. 데모(.is-demo)는 스스로 반복한다.
var t = document.querySelector('.menu__trigger'), s = document.querySelector('.menu__sheet');
t.addEventListener('click', function () {
t.classList.remove('is-demo');
t.setAttribute('aria-expanded', s.classList.toggle('is-open'));
});
document.addEventListener('pointerdown', function (e) {
if (s.classList.contains('is-open') && !s.contains(e.target) && e.target !== t) { s.classList.remove('is-open'); t.setAttribute('aria-expanded', false); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MobileFullSheetDropdown.module.scss";
export type MobileFullSheetDropdownProps = {
triggerLabel: string;
items: string[];
/** 시트가 올라오는 시간 (기본 300ms) */
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
export default function MobileFullSheetDropdown({
triggerLabel,
items,
duration = 300,
easing = "ease-out",
color = "#0a4fe8",
}: MobileFullSheetDropdownProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
function onDown(e: PointerEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("pointerdown", onDown);
return () => document.removeEventListener("pointerdown", onDown);
}, []);
return (
<div className={styles.stage} ref={ref} style={vars}>
<button type="button" className={styles.trigger} aria-expanded={open} onClick={() => setOpen((v) => !v)}>
{triggerLabel}
</button>
<ul className={open ? `${styles.sheet} ${styles.sheetOpen}` : styles.sheet}>
{items.map((label) => (
<li key={label}><a href="#">{label}</a></li>
))}
</ul>
</div>
);
}09 · 모바일 전체 시트 — 측정 누적면적 62.561% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디에 쓰나
반응형 헤더의 더보기 메뉴에 씁니다.
.menu__sheet {
position: absolute;
inset: auto 0 0 0;
transform: translateY(100%);
}
.menu__sheet.is-open {
transform: translateY(0);
transition: transform $duration $easing;
}
어디서 깨지는가 — 함정 하나
그리드 트랙 재계산 비용
01의 grid-template-rows 트릭은 height나 top을 직접 애니메이션하지 않는데도 브라우저가 매 프레임 그리드 트랙 크기를 다시 계산합니다. MDN의 CSS 그리드 레이아웃 문서가 이 값의 계산 비용을 설명합니다. 항목이 아주 많은 메뉴(수십 줄)에서는 이 트릭 대신 interpolate-size나 calc-size()를 쓰는 게 낫지만, 두 속성 모두 아직 지원이 갈립니다(caniuse의 interpolate-size 지원표).
popover 의 기본 위치
함정은 아니지만 짚어야 할 게 두 개 있습니다. 08 popover는 기본 상태로 두면 화면 한가운데 뜨는 UA 기본 위치를 씁니다. 트리거 바로 아래에 붙이려면 CSS 앵커 포지셔닝이 필요한데, 이 기능은 브라우저마다 지원 범위가 다릅니다(caniuse의 CSS 앵커 포지셔닝 지원표). 이 값들이 이미 반영된 아홉 개 파일의 압축 비밀번호는 wncdnnc7이고, 아래 버튼으로 받을 수 있습니다.
지연은 여는 쪽에만
다른 하나는 07 지연 열림입니다. transition-delay를 여는 쪽에만 걸고 닫는 쪽엔 걸지 않아야 마우스를 빠르게 치웠을 때 메뉴가 곧장 닫힙니다. 두 방향에 같은 지연을 걸면 사용자는 메뉴가 "안 닫힌다"고 느낍니다. 메뉴 항목 자체를 계속 흘려 보여야 하는 자리라면 여닫이 대신 CSS 무한 롤링 배너가 같은 헤더 자리의 다른 답입니다.
접근성
동작 줄이기를 켰을 때
아홉 개 전부 prefers-reduced-motion: reduce를 처리해서, 운영체제에서 "동작 줄이기"를 켠 사람에게는 트랜지션 없이 상태만 즉시 바뀝니다. 열고 닫는 기능 자체는 지우지 않는 게 원칙입니다. 01은 트랜지션 없이 즉시 접히고 펴지며, 07은 지연 없이 즉시 열립니다. 트리거 버튼에는 항상 aria-expanded를 붙이고, 08의 popover는 Esc로 닫히는 것까지 브라우저가 맡아 처리합니다. 값 조합은 MDN의 @media prefers-reduced-motion 문서에 정리돼 있습니다.
@media (prefers-reduced-motion: reduce) {
.menu__panel { transition: none !important; }
.menu.is-demo .menu__panel { animation: none; }
}
다른 전환·등장 주제는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.
FAQ
css 전체 드롭다운 메뉴에 꼭 JS가 필요합니까?
아닙니다. 08처럼 popover 속성만으로 열고 닫기·바깥 클릭·포커스 이동까지 브라우저가 처리합니다. 다만 caniuse의 popover 지원표를 먼저 확인하십시오. 나머지 여덟 개는 클릭 토글에 짧은 JS 몇 줄만 씁니다. 06 메가메뉴의 배경 채우기처럼 이 규칙들을 유틸 클래스로 옮겨 쓰고 싶다면 Tailwind 애니메이션 유틸이 그 이동을 보여 줍니다.
09 모바일 시트와 01 데스크톱 드롭다운을 같이 써도 됩니까?
네, 흔한 조합입니다. 미디어 쿼리로 좁은 화면에서는 09의 마크업을, 넓은 화면에서는 01이나 02의 마크업을 보여주면 됩니다. zip의 react/ 폴더는 각각 별도 컴포넌트라 화면 폭에 따라 골라 렌더링하면 됩니다.
메뉴가 버튼 위치와 다른 곳에 뜹니다.
트리거를 감싸는 요소에 position: relative가 있는지 먼저 확인하십시오. 08 popover는 예외로, 기본은 화면 중앙에 뜨고 트리거 옆에 붙이려면 앵커 포지셔닝이 따로 필요합니다.









