<dialog>나 Popover API로 모달을 띄우는 글을 쓰고 나서 한 가지 아쉬운 게 남았다. 열고 닫히는 건 되는데, 그냥 툭 나타났다가 툭 사라진다. 페이드인 하나 넣으려고 하면 바로 벽에 부딪힌다.
왜 그동안 안 됐나
트랜지션은 "시작 값"과 "끝 값"이 둘 다 있어야 걸린다. 그런데 요소가 방금 display: none에서 나타났다면, 브라우저 입장에서 그 요소는 조금 전까지 렌더 트리에 없었다. 시작 값 자체가 없으니 트랜지션이 안 걸리는 거다. 그래서 다들 requestAnimationFrame으로 클래스를 한 프레임 늦게 붙이거나, 라이브러리를 끌어다 썼다.
두 가지가 새로 생기면서 이게 CSS만으로 해결된다.
@starting-style— 요소가 처음 렌더링될 때의 "시작 값"을 지정한다.transition-behavior: allow-discrete—display나overlay처럼 원래 애니메이션이 안 되던 속성도 트랜지션 타임라인에 태운다.
실제 코드
Popover에 페이드 + 살짝 떠오르는 등장 애니메이션을 걸어보자.
.card {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.25s ease,
transform 0.25s ease,
display 0.25s allow-discrete;
}
/* 열리기 직전, 화면에 그려지는 첫 순간의 값 */
@starting-style {
.card {
opacity: 0;
transform: translateY(8px);
}
}
/* 닫힐 때의 값 (popover가 닫히면 display:none이 됨) */
.card:not(:popover-open) {
opacity: 0;
transform: translateY(8px);
}
핵심은 세 군데다. 평상시 상태(.card), 등장 첫 프레임(@starting-style), 그리고 사라질 때 상태. transition에 display ... allow-discrete를 넣어줬기 때문에 닫힐 때 display: none이 곧바로 적용되지 않고, opacity가 0이 될 때까지 기다렸다가 사라진다.
<dialog>도 똑같다. 선택자만 .dialog[open] / .dialog:not([open])으로 바꾸면 된다. backdrop에도 ::backdrop으로 같은 패턴을 그대로 쓸 수 있다.
걸렸던 점 하나
@starting-style 안의 선택자는 밖의 규칙보다 우선순위가 낮게 취급된다. 그래서 등장 값이 안 먹으면 대부분 밖에 있는 규칙이 더 구체적이어서 덮어쓰는 경우다. 나는 처음에 .card.open 같은 걸로 열림 상태를 잡아놨다가 시작 값이 계속 무시돼서 한참 헤맸다. @starting-style 안쪽 선택자를 밖과 맞춰주니 바로 됐다.
정리
지원 범위는 이제 Chrome, Safari, Firefox 최신 버전에서 다 된다. 등장 애니메이션 하나 넣자고 상태 플래그와 setTimeout을 끌어오던 시절이 좀 억울해진다. 모달·토스트·드롭다운처럼 나타났다 사라지는 UI라면, JS 로직은 그대로 두고 CSS 세 블록만 추가하면 끝난다.