`@starting-style`로 팝오버(popover)·다이얼로그(dialog)에 순수 CSS 진입·퇴장 애니메이션 추가하기
팝오버가 열릴 때마다 툭 튀어나오는 그 느낌, 한 번쯤은 거슬렸을 거예요. 닫힐 땐 또 그냥 사라지고. 부드럽게 만들어보려고 JS 코드를 열면 어김없이 이렇게 됩니다.
el.classList.add('is-closing');
el.addEventListener('transitionend', () => {
el.classList.remove('is-open', 'is-closing');
}, { once: true });딱 이 패턴입니다. 클래스 토글하고, transitionend 이벤트 등록하고, 타이밍 맞추다가 엣지 케이스 터지고, 결국 타이머 박아버리는 그 과정. 저도 이 삽질을 여러 번 했습니다.
그런데 이제는 그럴 필요가 없어졌습니다. @starting-style과 transition-behavior: allow-discrete, 그리고 overlay 전환을 조합하면 JS 코드 없이 순수 CSS만으로 팝오버·다이얼로그의 진입·퇴장 애니메이션을 완성할 수 있습니다. 현재 Chrome, Edge, Safari, Firefox 모든 주요 브라우저가 이 기능을 지원하며, 브라우저 지원율이 86%를 넘어 실무 적용이 현실적인 선택지가 됐습니다.
세 기술이 왜 함께 필요한지 개념부터 짚어보고, Popover API와 <dialog> 모달 두 가지 시나리오에 바로 쓸 수 있는 코드를 살펴볼게요. prefers-reduced-motion 접근성 처리까지 포함해서요.
핵심 개념
세 기술이 각각 어떤 문제를 해결하는지 미리 파악해두면 코드가 훨씬 읽기 쉬워집니다.
| 기술 | 담당 역할 |
|---|---|
@starting-style |
진입(entry) 애니메이션의 시작점 정의 |
transition-behavior: allow-discrete |
display, overlay 같은 이산 속성을 transition 대상에 포함 |
overlay 전환 |
퇴장(exit) 애니메이션이 끝날 때까지 top-layer 잔류 보장 |
세 가지 중 하나만 빠져도 진입 또는 퇴장 어느 한쪽이 끊깁니다. 처음엔 저도 이걸 몰라서 "왜 진입만 되고 퇴장이 안 되지?"를 한참 헤맸었거든요.
왜 CSS transition만으로는 진입 애니메이션이 안 됐을까
CSS transition은 "이전 상태 → 현재 상태"를 보간(interpolate)하는 방식으로 동작합니다. display: none에서 막 등장한 요소나 페이지에 처음 마운트된 요소는 브라우저 입장에서 "이전 상태"가 없습니다. 시작점이 없으니 전환도 없고, 그래서 그냥 탁 나타나는 거죠.
@starting-style은 바로 이 문제를 해결하는 CSS at-rule입니다. "이 요소가 처음 화면에 나타날 때, 여기서부터 시작해줘"라고 브라우저에게 알려주는 셈입니다.
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
@starting-style— 요소가display: none에서 가시 상태로 전환되거나 처음 렌더링될 때 CSS transition의 시작점 스타일을 정의하는 at-rule. CSS 애니메이션(@keyframes)이 아닌 transition에만 적용됩니다.
transition-behavior: allow-discrete가 필요한 이유
opacity나 transform은 값이 연속적으로 변하기 때문에 transition이 잘 작동합니다. 반면 display 속성은 none에서 block으로 순간적으로 바뀌는 이산(discrete) 속성이라 기본 동작으로는 transition 대상에 포함조차 되지 않아요.
transition-behavior: allow-discrete를 지정하면 이런 이산 속성도 전환 대상에 포함할 수 있습니다. 팝오버가 닫힐 때 display: none이 즉각 적용되어 퇴장 애니메이션이 중단되는 현상을 막는 핵심 설정입니다.
CSS shorthand로 쓸 때의 표기가 처음엔 낯설 수 있는데, 풀어쓰면 이렇게 됩니다.
/* shorthand: 속성명 지속시간 allow-discrete */
display 0.3s allow-discrete
/* 동일한 의미의 longhand */
transition-property: display;
transition-duration: 0.3s;
transition-behavior: allow-discrete;overlay 전환으로 퇴장 애니메이션 보호하기
팝오버와 다이얼로그는 일반 DOM 위에 렌더링되는 top-layer라는 특별한 레이어에 배치됩니다. 퇴장 애니메이션이 진행되는 도중 요소가 top-layer에서 제거되면 애니메이션이 뚝 끊겨버립니다.
overlay 속성을 transition 목록에 추가하면 브라우저가 퇴장 애니메이션이 끝날 때까지 요소를 top-layer에 유지해줍니다. 이 속성은 사용자가 임의로 값을 설정할 수 없고 브라우저가 top-layer 요소에 자동으로 부여하는 속성인데, transition 대상에 포함시킴으로써 제거 타이밍을 지연시키는 방식입니다.
top-layer — 팝오버·다이얼로그가 다른 모든 요소 위에 표시될 수 있도록 브라우저가 관리하는 특별한 렌더링 레이어.
z-index와 무관하게 항상 최상위에 위치합니다.
실전 적용
예시 1: Popover API 진입·퇴장 애니메이션
HTML popover 속성을 처음 접하는 분들을 위해 간단히 설명하면, popover 속성 하나만 추가하면 브라우저가 접근성, 포커스 관리, Escape 키 처리를 모두 담당해줍니다. 버튼에 popovertarget만 연결하면 JS 없이 열고 닫을 수 있어요. 자세한 내용은 Popover API MDN 문서에서 확인해볼 수 있습니다.
<button popovertarget="my-popover">메뉴 열기</button>
<div id="my-popover" popover>
<p>팝오버 내용입니다.</p>
</div>[popover] {
/* 위치 설정 — 없으면 뷰포트 밖으로 나갈 수 있습니다 */
margin: auto;
/* 닫힌 상태 스타일 */
opacity: 0;
transform: translateY(-8px) scale(0.96);
transition:
opacity 0.3s ease,
transform 0.3s ease,
display 0.3s allow-discrete, /* ease는 이산 속성에 의미 없어 생략 */
overlay 0.3s allow-discrete;
}
/* 열린 상태 — transition의 목표 스타일 */
[popover]:popover-open {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 진입 시작점 — 처음 등장할 때 여기서 출발 */
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
}
opacity와transform에는ease가 붙지만display와overlay에는 없습니다.display: none → block같은 이산 전환은 값이 단계적으로 변하지 않아 easing 곡선 자체가 의미 없기 때문입니다.
| 부분 | 역할 |
|---|---|
[popover] 기본 스타일 |
닫힌 상태 스타일 + 전체 transition 정의 |
[popover]:popover-open |
열린 상태 (transition 목표값), CSS 의사 클래스 사용 |
@starting-style 내부 |
진입 애니메이션의 출발값 |
display 0.3s allow-discrete |
닫힐 때 display: none 즉각 적용 방지 |
overlay 0.3s allow-discrete |
퇴장 애니메이션 동안 top-layer 유지 |
예시 2: <dialog> 모달 + ::backdrop 페이드 애니메이션
<dialog>는 showModal()로 열고 close()로 닫습니다. 열린 상태는 HTML 속성 [open]으로 감지하는데, Popover API의 :popover-open 의사 클래스와 역할은 같지만 표기가 다릅니다. 한 가지 짚고 가자면, "순수 CSS로 애니메이션 처리"라는 범위이지 <dialog> 자체를 여닫는 건 JS가 필요합니다.
<button id="open-btn">모달 열기</button>
<dialog id="my-dialog">
<p>다이얼로그 내용입니다.</p>
<button id="close-btn">닫기</button>
</dialog>
<script>
const dialog = document.getElementById('my-dialog');
document.getElementById('open-btn').addEventListener('click', () => dialog.showModal());
document.getElementById('close-btn').addEventListener('click', () => dialog.close());
</script>dialog {
margin: auto;
opacity: 0;
transform: scale(0.9);
transition:
opacity 0.25s ease,
transform 0.25s ease,
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog[open] {
opacity: 1;
transform: scale(1);
}
@starting-style {
dialog[open] {
opacity: 0;
transform: scale(0.9);
}
}
/* 배경 페이드 인/아웃 */
dialog::backdrop {
background-color: rgb(0 0 0 / 0%);
transition:
background-color 0.25s ease,
display 0.25s allow-discrete,
overlay 0.25s allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 50%);
}
@starting-style {
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}::backdrop도 독립된 렌더링 요소로 취급되기 때문에 별도로 @starting-style을 정의해줘야 합니다. 이걸 빠뜨리면 다이얼로그 본체는 페이드 인이 되는데 배경만 탁 나타나는 어색한 결과가 나옵니다.
예시 3: prefers-reduced-motion 접근성 처리
전정기관 장애가 있는 사용자에게 과한 움직임은 실제 신체적 불편함을 줄 수 있습니다. 다음 코드로 충분히 대응할 수 있습니다.
@media (prefers-reduced-motion: reduce) {
[popover],
dialog,
dialog::backdrop {
transition-duration: 0.01ms;
}
}전환 시간을 사실상 0으로 줄여서 즉각 표시되도록 하는 방식입니다. 코드 몇 줄이지만 실제 사용자에게는 큰 차이가 됩니다.
prefers-reduced-motion— 사용자가 OS 설정에서 "동작 줄이기"를 활성화했는지 감지하는 미디어 쿼리. 모션 효과를 쓸 때마다 함께 챙기는 것을 권장합니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 순수 CSS 구현 | JS 클래스 토글, transitionend 이벤트 핸들러 없이 CSS만으로 처리 |
| GPU 가속 성능 | opacity·transform은 compositor 레이어에서 처리되어 메인 스레드 부하 없음 |
| 선언적 유지보수 | 타이밍 로직이 CSS에 집중되어 버그 발생 포인트가 줄어듦 |
| 네이티브 API 통합 | Popover API, <dialog>와 별도 설정 없이 자연스럽게 연동 |
compositor 레이어 —
opacity와transform은 레이아웃이나 페인트 재계산 없이 GPU에서 직접 처리됩니다. 이 두 속성만으로 애니메이션을 구성하면 60fps 유지가 훨씬 쉬워집니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
<dialog> 퇴장 타이밍 |
close() 호출 시 즉시 닫히는 특성으로 퇴장 애니메이션이 끊길 수 있음 |
display와 overlay 모두 transition에 포함하여 지연 처리 |
overlay 속성 특성 |
사용자가 임의로 값을 설정할 수 없고 브라우저가 top-layer 요소에 자동 부여 | 퇴장 지연 목적으로만 transition에 포함하고 직접 값을 설정하지 않음 |
@keyframes와 혼용 불가 |
CSS 애니메이션에는 @starting-style이 적용되지 않음 |
진입·퇴장 효과가 필요한 경우 @keyframes 대신 transition으로 전환 |
| 초기 스타일 명시 필요 | @starting-style 내부 스타일이 기본값과 동일하면 트리거되지 않음 |
명시적으로 시작값을 선언하고 기본 스타일과 다름을 확인 |
| 브라우저 지원율 86% | 약 14% 사용자에게 애니메이션 없이 즉시 표시될 수 있음 | 즉시 표시를 기본 동작으로 수용하거나 @supports로 폴백 처리 |
실무에서 가장 흔한 실수
-
display와overlay를 transition에서 빠뜨리는 것 — 진입 애니메이션은 되는데 퇴장이 툭 끊긴다면 이 두 속성이 빠진 경우가 대부분입니다. 저도 처음에 "왜 진입은 되는데 퇴장은 안 되지?" 하고 한참 헤맸는데, 알고 보면 딱 이거였어요.allow-discrete와 함께 반드시 포함시켜 주어야 합니다. -
@starting-style을 열린 상태 셀렉터 없이 작성하는 것 —@starting-style내부에는 반드시 열린 상태 셀렉터(:popover-open,[open])를 그대로 사용해야 합니다. 기본 요소 셀렉터([popover],dialog)에 작성하면 동작하지 않습니다. 처음엔 "열린 상태를 안에 왜 또 써야 하지?" 싶은데,@starting-style이 "이 열린 상태로 진입할 때의 시작값"을 정의하는 구조라 그렇습니다. -
::backdrop의@starting-style을 빠뜨리는 것 — 다이얼로그 본체에만 적용하고::backdrop을 놓치면 배경이 페이드 없이 즉시 나타납니다.::backdrop은 독립된 렌더링 요소라 각자@starting-style이 필요합니다. 이게 독립된 요소라는 게 처음엔 직관적으로 와닿지 않아서 저도 한동안 빠뜨렸었거든요.
마치며
@starting-style + transition-behavior: allow-discrete + overlay 전환, 이 세 가지 조합이면 JavaScript 없이 팝오버·다이얼로그의 진입·퇴장 애니메이션을 완성할 수 있습니다. 모든 주요 브라우저가 지원하는 지금이 실무에 도입하기 좋은 시점입니다.
지금 바로 시작해볼 수 있는 3단계:
-
기존 프로젝트의 팝오버나 다이얼로그 CSS를 열고,
transition속성에display 0.3s allow-discrete, overlay 0.3s allow-discrete를 추가해볼 수 있습니다. 퇴장 애니메이션이 끊기지 않는지 먼저 확인해보시면 좋습니다. -
진입 애니메이션을 위해
@starting-style { [셀렉터]:open-state { opacity: 0; transform: ... } }블록을 추가해볼 수 있습니다.opacity: 0과 약간의transform이면 충분히 자연스러운 효과가 나옵니다. -
@media (prefers-reduced-motion: reduce)블록으로transition-duration: 0.01ms를 추가해 접근성 처리까지 마무리하는 것을 권장합니다. 코드 두 줄이지만 실제 사용자에게는 큰 차이가 됩니다.
참고 자료
- MDN - @starting-style CSS at-rule
- Chrome for Developers - Four new CSS features for smooth entry and exit animations
- web.dev - Now in Baseline: animating entry effects
- Smashing Magazine - Transitioning Top-Layer Entries And The Display Property In CSS (2025.01)
- LogRocket Blog - Animating dialog and popover elements with CSS @starting-style
- Frontend Masters Blog - The Dialog Element with Entry and Exit Animations
- Josh W. Comeau - The Big Gotcha With @starting-style
- CSS-Tricks - @starting-style
- MDN - transition-behavior CSS property
- MDN - overlay CSS property
- pawelgrzybek.com - Popover element entry and exit animations in a few lines of CSS