문제 배경
팝오버나 모달을 열 때 살짝 페이드인 효과를 주고 싶어서 opacity에 transition을 걸어 본 경험이 있을 겁니다. 그런데 막상 해 보면 열릴 때도 닫힐 때도 애니메이션 없이 바로 나타나고 사라집니다. 이유는 두 가지입니다.
- CSS transition은 이전 스타일과 새 스타일 사이를 보간합니다.
display: none이던 요소나 DOM에 새로 추가된 요소에는 "이전 스타일"이 없어서 transition이 시작되지 않습니다. display는 중간값이 없는 이산(discrete) 속성이라, 기본 동작에서는 transition 대상이 아닙니다. 닫힐 때display: none이 즉시 적용되어 요소가 바로 사라집니다.
그래서 지금까지는 setTimeout으로 클래스를 한 프레임 늦게 붙이거나, transitionend 이벤트를 기다렸다가 hidden을 거는 식의 JavaScript 우회가 흔했습니다. CSS Transitions Level 2에 들어간 @starting-style과 transition-behavior: allow-discrete를 쓰면 이 작업을 CSS만으로 처리할 수 있습니다.
핵심 개념
@starting-style: transition의 출발점 지정
@starting-style은 이전 스타일이 없는 요소에 "여기서부터 transition하라"는 시작값을 알려 주는 규칙입니다. 명세에 따르면 이 규칙은 직전 스타일 계산 시점에 렌더링되지 않았거나 DOM에 없던 요소에만 적용됩니다. 이미 화면에 그려진 요소에는 영향이 없으므로, 일반 상태 스타일을 덮어쓸 걱정은 하지 않아도 됩니다.
두 가지 방식으로 쓸 수 있습니다.
/* 1. 독립 블록 */
.toast {
opacity: 1;
transition: opacity 0.3s;
}
@starting-style {
.toast {
opacity: 0;
}
}
/* 2. 규칙 안에 중첩 */
.toast {
opacity: 1;
transition: opacity 0.3s;
@starting-style {
opacity: 0;
}
}독립 블록으로 쓸 때는 @starting-style 안의 규칙과 원래 규칙의 명시도가 같습니다. 따라서 원래 규칙보다 뒤에 두어야 시작값이 적용됩니다. 앞에 두면 원래 규칙이 덮어씁니다.
위 예제처럼 .toast 요소를 JavaScript로 DOM에 추가하면, 브라우저는 opacity: 0에서 출발해 1로 0.3초 동안 transition합니다. 별도의 프레임 지연 코드가 필요 없습니다.
transition-behavior: allow-discrete: 이산 속성도 transition 대상으로
transition-behavior의 기본값은 normal이고, 이때 display 같은 이산 속성은 transition되지 않습니다. allow-discrete로 바꾸면 이산 속성에도 transition이 시작됩니다.
이산 속성은 보통 transition 시간의 50% 지점에서 값이 바뀝니다. 다만 display: none과 content-visibility: hidden은 예외입니다.
- none으로 갈 때(닫힘): 100% 지점, 즉 transition이 끝날 때 값이 바뀝니다. 그동안 요소는 보이는 상태로 남아 퇴장 애니메이션이 재생됩니다.
- none에서 올 때(열림): 0% 지점, 즉 시작하자마자 값이 바뀝니다. 요소가 바로 보이므로 등장 애니메이션 전체를 볼 수 있습니다.
transition 단축 속성 안에서 속성별로 지정할 수 있습니다.
.panel {
transition:
opacity 0.25s,
display 0.25s allow-discrete;
}overlay: 최상위 레이어(top layer)에서 빠지는 시점 늦추기
팝오버와 모달 <dialog>는 top layer에 올라가 렌더링됩니다. 닫히는 순간 top layer에서 바로 빠지면 퇴장 애니메이션이 다른 요소 아래에 가려지거나 위치가 바뀔 수 있습니다. overlay 속성을 transition 목록에 allow-discrete와 함께 넣으면 애니메이션이 끝날 때까지 top layer에 머무릅니다. 참고로 overlay 값은 브라우저만 설정할 수 있고, 작성자는 transition 목록에 넣는 용도로만 씁니다.
실무 예제
팝오버 열기·닫기 애니메이션
지난 글에서 다룬 Popover API와 그대로 조합할 수 있습니다. JavaScript 없이 버튼으로 팝오버를 열고 닫으면서 페이드·스케일 효과를 줍니다.
<button type="button" popovertarget="menu">메뉴 열기</button>
<div id="menu" popover>
<p>팝오버 내용입니다.</p>
</div>/* 닫힌 상태이자 퇴장 애니메이션의 도착 상태 */
[popover] {
opacity: 0;
transform: scale(0.95);
transition:
opacity 0.2s ease-out,
transform 0.2s ease-out,
overlay 0.2s ease-out allow-discrete,
display 0.2s ease-out allow-discrete;
}
/* 열린 상태 */
[popover]:popover-open {
opacity: 1;
transform: scale(1);
}
/* 등장 애니메이션의 출발 상태: 반드시 :popover-open 규칙보다 뒤에 둔다 */
@starting-style {
[popover]:popover-open {
opacity: 0;
transform: scale(0.95);
}
}동작 순서를 정리하면 다음과 같습니다.
- 열 때:
display가 0% 지점에서 바로 바뀌어 요소가 보이고,@starting-style의 값에서:popover-open의 값으로 transition합니다. - 닫을 때:
:popover-open이 해제되어 기본[popover]스타일로 transition하고,display: none과 top layer 이탈은 0.2초가 지난 뒤에 적용됩니다.
모달 dialog와 backdrop
showModal()로 연 <dialog>도 같은 방식입니다. ::backdrop 같은 의사 요소는 중첩 문법으로 쓸 수 없으므로 독립 @starting-style 블록을 사용합니다.
<button type="button" id="open">대화상자 열기</button>
<dialog id="dialog">
<p>저장하지 않은 변경 사항이 있습니다.</p>
<form method="dialog">
<button>닫기</button>
</form>
</dialog>
<script>
const dialog = document.getElementById('dialog');
document.getElementById('open').addEventListener('click', () => {
dialog.showModal();
});
</script>dialog {
opacity: 0;
translate: 0 1rem;
transition:
opacity 0.25s,
translate 0.25s,
overlay 0.25s allow-discrete,
display 0.25s allow-discrete;
}
dialog[open] {
opacity: 1;
translate: 0 0;
}
@starting-style {
dialog[open] {
opacity: 0;
translate: 0 1rem;
}
}
dialog::backdrop {
background-color: rgb(0 0 0 / 0%);
transition:
background-color 0.25s,
overlay 0.25s allow-discrete,
display 0.25s allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 40%);
}
@starting-style {
dialog[open]::backdrop {
background-color: rgb(0 0 0 / 0%);
}
}DOM에 추가되는 요소의 등장 애니메이션
토스트나 목록 항목처럼 JavaScript로 새로 붙이는 요소는 display 전환이 없으므로 @starting-style만 있으면 됩니다.
.list-item {
opacity: 1;
translate: 0 0;
transition: opacity 0.3s, translate 0.3s;
@starting-style {
opacity: 0;
translate: 0 -0.5rem;
}
}const list = document.querySelector('.list');
function addItem(text) {
const item = document.createElement('li');
item.className = 'list-item';
item.textContent = text;
list.append(item); // 추가되는 즉시 등장 transition이 실행된다
}단, 요소를 remove()로 제거하면 그 즉시 DOM에서 사라지므로 퇴장 애니메이션은 이 방법으로 만들 수 없습니다. 퇴장 효과가 필요하면 클래스를 바꿔 transition을 실행한 뒤 transitionend에서 제거하는 기존 방식을 함께 써야 합니다.
주의사항
- 선언 순서: 독립
@starting-style블록은 원래 규칙보다 뒤에 둡니다. 명시도가 같아서 앞에 두면 무시됩니다. - 단축 속성이 allow-discrete를 덮어쓰는 문제:
transition단축 속성에transition-behavior를 적지 않으면 초기값normal로 재설정됩니다.transition-behavior: allow-discrete를 따로 쓴다면 단축 속성 뒤에 선언하거나, 위 예제처럼 단축 속성 안에 속성별로 적는 편이 안전합니다. - transition 전용:
@starting-style은 CSS transition을 위한 것입니다.@keyframes애니메이션은 처음 렌더링될 때도 실행되므로 필요하지 않습니다. - 브라우저 지원:
@starting-style과transition-behavior는 MDN 기준 Baseline 2024로 주요 최신 브라우저에서 사용할 수 있습니다(Chrome은 117부터). 반면overlay는 아직 Baseline이 아니며 Firefox에서 지원하지 않습니다. 지원하지 않는 브라우저에서는 퇴장 시 top layer에서 먼저 빠질 수 있지만, 팝오버나 대화상자 자체는 정상적으로 열리고 닫힙니다. 애니메이션은 점진적 향상으로 다루면 됩니다. - 움직임 줄이기 설정 존중: 사용자가 OS에서 움직임 줄이기를 켰다면 이동·확대 효과를 빼는 것이 좋습니다. transition 목록에서 해당 속성을 빼면 값은 즉시 바뀌고 투명도 변화만 남습니다.
/* 이동·확대는 transition에서 빼고 투명도 변화만 남긴다 */
@media (prefers-reduced-motion: reduce) {
[popover],
dialog {
transition:
opacity 0.2s,
overlay 0.2s allow-discrete,
display 0.2s allow-discrete;
}
}정리
@starting-style은 이전 스타일이 없는 요소(새로 추가되었거나display: none에서 나타나는 요소)에 transition 시작값을 지정합니다.transition-behavior: allow-discrete를 쓰면display가 열릴 때는 바로, 닫힐 때는 transition이 끝난 뒤 바뀌어 등장·퇴장 애니메이션이 모두 보입니다.- 팝오버와 모달에는
overlay도 transition 목록에 넣어 top layer 이탈을 늦춥니다. - 세 가지를 조합하면
setTimeout이나transitionend없이 CSS만으로 열기·닫기 애니메이션을 만들 수 있습니다.

댓글 0