GODRICH

CSS 드롭다운 메뉴 애니메이션 9종 — JS 없이, CSS만으로

CSS 드롭다운 메뉴 애니메이션은 트리거를 누르거나 올렸을 때 패널이 어떻게 나타나고 사라지는지를 정하는 짧은 전환입니다. 아홉 개 중 하나(08)는 자바스크립트를 한 줄도 안 씁니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 개는 흔히 보는 순서가 아니라 메뉴가 실제로 붙는 자리 순서로 놓았습니다. 일반 내비게이션(01~05), 넓은 카테고리 메뉴(06), 헤더에 여러 개가 나란한 경우(07), 가벼운 액션 메뉴(08), 좁은 화면(09)입니다. 아래 코드는 여닫는 핵심 규칙만 뽑았고, 트리거·마크업까지 다 붙은 파일은 zip 안에 있습니다.

01height:auto 트랜지션

height: auto는 애니메이션할 수 없다는 게 CSS의 오래된 제약입니다. 대신 grid-template-rows0fr에서 1fr로 트랜지션하면 실제 콘텐츠 높이까지 부드럽게 늘어나는 것처럼 보입니다. 자식 요소에 overflow: hidden을 걸어야 늘어나는 중간 과정이 잘려 보이지 않습니다.

grid-template-rows0fr→1frheight:auto
.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 미끄러져 내려오며 나타납니다. 헤더의 계정 메뉴처럼 클릭 한 번으로 여닫히는 자리에 씁니다. 메뉴 바깥을 클릭하면 닫히는 리스너 하나면 충분합니다.

opacity+translateY0.18초ease-out
.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으로 삼아 그 지점에서부터 확대되며 열립니다. 우측 상단 프로필 메뉴처럼 화면 끝에 붙는 메뉴에 잘 맞습니다.

transform-originscale(0.95→1)0.15초
.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 사각형이 트리거 아래쪽에서부터 펼쳐지며 메뉴 내용을 잘라낸 채로 드러냅니다. 카드 안 컨텍스트 메뉴처럼 좁은 자리에 어울립니다.

clip-pathinset()0.2초
.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항목 순차 등장

메뉴가 열리면 항목들이 한꺼번에 나타나지 않고 위에서부터 시차를 두고 하나씩 등장합니다. 항목이 많은 설정 메뉴에서 한눈에 다 쏟아지는 느낌을 줄여줍니다.

transition-delay20ms 간격순차 등장
.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메가메뉴 배경 채우기

메가메뉴 배경 패널이 항목 폭에 맞춰 옆으로 번지듯 채워지며 다단 카테고리를 한 판에 펼칩니다. 쇼핑몰 헤더처럼 카테고리가 많을 때 씁니다.

width 확장배경 번짐다단 그리드
.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를 기다렸다가 메뉴를 엽니다. 헤더에 메뉴 여러 개가 나란할 때 커서가 스치기만 해도 전부 열려버리는 걸 막습니다.

transition-delay150ms 지연hover-intent
.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만 걸면 됩니다.

popover::backdropJS 없음
<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모바일 전체 시트

좁은 화면에서는 드롭다운 대신 메뉴가 화면 아래에서 전체 폭 시트로 올라와 손가락으로 누르기 쉬운 크기가 됩니다. 반응형 헤더의 더보기 메뉴에 씁니다.

미디어쿼리bottom-sheet전체 폭
.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 트릭은 heighttop을 직접 애니메이션하지 않는데도 브라우저가 매 프레임 그리드 트랙 크기를 다시 계산합니다. MDN의 CSS 그리드 레이아웃 문서가 이 값의 계산 비용을 설명합니다. 항목이 아주 많은 메뉴(수십 줄)에서는 이 트릭 대신 interpolate-sizecalc-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는 예외로, 기본은 화면 중앙에 뜨고 트리거 옆에 붙이려면 앵커 포지셔닝이 따로 필요합니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.