CSS 드롭다운 메뉴 애니메이션 9종 — JS 없이, CSS만으로
CSS 드롭다운 메뉴 애니메이션은 트리거를 누르거나 올렸을 때 패널이 어떻게 나타나고 사라지는지를 정하는 짧은 전환입니다. 아홉 개 중 하나(08)는 자바스크립트를 한 줄도 안 씁니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 height:auto 트랜지션
- 02 페이드+슬라이드
- 03 모서리 기준 확대
- 04 클립패스 오픈
- 05 항목 순차 등장
- 06 메가메뉴 배경 채우기
- 07 지연 열림(transition-delay)
- 08 popover 속성 사용
- 09 모바일 전체 시트
아홉 개는 흔히 보는 순서가 아니라 메뉴가 실제로 붙는 자리 순서로 놓았습니다. 일반 내비게이션(01~05), 넓은 카테고리 메뉴(06), 헤더에 여러 개가 나란한 경우(07), 가벼운 액션 메뉴(08), 좁은 화면(09)입니다. 아래 코드는 여닫는 핵심 규칙만 뽑았고, 트리거·마크업까지 다 붙은 파일은 zip 안에 있습니다.
01height:auto 트랜지션
height: auto는 애니메이션할 수 없다는 게 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 미끄러져 내려오며 나타납니다. 헤더의 계정 메뉴처럼 클릭 한 번으로 여닫히는 자리에 씁니다. 메뉴 바깥을 클릭하면 닫히는 리스너 하나면 충분합니다.
.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으로 삼아 그 지점에서부터 확대되며 열립니다. 우측 상단 프로필 메뉴처럼 화면 끝에 붙는 메뉴에 잘 맞습니다.
.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 사각형이 트리거 아래쪽에서부터 펼쳐지며 메뉴 내용을 잘라낸 채로 드러냅니다. 카드 안 컨텍스트 메뉴처럼 좁은 자리에 어울립니다.
.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항목 순차 등장
메뉴가 열리면 항목들이 한꺼번에 나타나지 않고 위에서부터 시차를 두고 하나씩 등장합니다. 항목이 많은 설정 메뉴에서 한눈에 다 쏟아지는 느낌을 줄여줍니다.
.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메가메뉴 배경 채우기
메가메뉴 배경 패널이 항목 폭에 맞춰 옆으로 번지듯 채워지며 다단 카테고리를 한 판에 펼칩니다. 쇼핑몰 헤더처럼 카테고리가 많을 때 씁니다.
.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를 기다렸다가 메뉴를 엽니다. 헤더에 메뉴 여러 개가 나란할 때 커서가 스치기만 해도 전부 열려버리는 걸 막습니다.
.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 토글 없이 브라우저가 열고 닫기·바깥 클릭·포커스를 대신 관리합니다. 트리거 버튼에 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모바일 전체 시트
좁은 화면에서는 드롭다운 대신 메뉴가 화면 아래에서 전체 폭 시트로 올라와 손가락으로 누르기 쉬운 크기가 됩니다. 반응형 헤더의 더보기 메뉴에 씁니다.
.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 지원표).
함정은 아니지만 짚어야 할 게 두 개 있습니다. 08 popover는 기본 상태로 두면 화면 한가운데 뜨는 UA 기본 위치를 씁니다. 트리거 바로 아래에 붙이려면 CSS 앵커 포지셔닝이 필요한데, 이 기능은 브라우저마다 지원 범위가 다릅니다(caniuse의 CSS 앵커 포지셔닝 지원표). 이 값들이 이미 반영된 아홉 개 파일의 압축 비밀번호는 wncdnnc7이고, 아래 버튼으로 받을 수 있습니다.
다른 하나는 07 지연 열림입니다. transition-delay를 여는 쪽에만 걸고 닫는 쪽엔 걸지 않아야 마우스를 빠르게 치웠을 때 메뉴가 곧장 닫힙니다. 두 방향에 같은 지연을 걸면 사용자는 메뉴가 "안 닫힌다"고 느낍니다.
접근성
아홉 개 전부 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
드롭다운 메뉴에 꼭 JS가 필요합니까?
아닙니다. 08처럼 popover 속성만으로 열고 닫기·바깥 클릭·포커스 이동까지 브라우저가 처리합니다. 다만 caniuse의 popover 지원표를 먼저 확인하십시오. 나머지 여덟 개는 클릭 토글에 짧은 JS 몇 줄만 씁니다.
09 모바일 시트와 01 데스크톱 드롭다운을 같이 써도 됩니까?
네, 흔한 조합입니다. 미디어 쿼리로 좁은 화면에서는 09의 마크업을, 넓은 화면에서는 01이나 02의 마크업을 보여주면 됩니다. zip의 react/ 폴더는 각각 별도 컴포넌트라 화면 폭에 따라 골라 렌더링하면 됩니다.
메뉴가 버튼 위치와 다른 곳에 뜹니다.
트리거를 감싸는 요소에 position: relative가 있는지 먼저 확인하십시오. 08 popover는 예외로, 기본은 화면 중앙에 뜨고 트리거 옆에 붙이려면 앵커 포지셔닝이 따로 필요합니다.