'최상위 레이어'에서 렌더링되는 요소(예: <dialog>, popover 속성이 있는 요소 또는 도움말)는 display: none와 표시 상태 간에 전환되므로 지금까지 애니메이션을 적용하기가 어려웠습니다. 최신 CSS는 @starting-style, transition-behavior: allow-discrete, overlay 속성을 제공하여 이러한 요소의 원활한 진입 및 종료 전환을 지원합니다. 아래 예에서는 네이티브 CSS 중첩이 사용됩니다.
구현
1. 불연속 전환 사용 설정
display 속성에 애니메이션을 적용하려면 transition-behavior: allow-discrete를 설정해야 합니다. 이렇게 하면 종료 전환 중에 요소가 계속 표시됩니다. 전환 축약형을 사용하는 경우 축약형이 이를 무효화하지 않도록 transition-behavior: allow-discrete를 뒤에 배치해야 합니다.
2. overlay 속성
요소가 최상위 레이어로 이동하거나 최상위 레이어에서 벗어날 때 overlay 속성을 전환해야 합니다. 이렇게 하면 애니메이션이 진행되는 동안 요소가 최상위 레이어에 유지되므로 다른 요소나 표시 영역에 의해 조기에 잘리지 않습니다.
3. @starting-style를 사용한 항목 애니메이션
@starting-style at-rule을 사용하여 요소가 처음 렌더링되거나 display가 none에서 변경될 때 전환해야 하는 스타일을 정의합니다.
4. 배경 애니메이션
::backdrop 가상 요소는 자체 속성에 전환을 적용하여 유사하게 애니메이션 처리할 수 있습니다.
예
/* 1. Define the visible (open) state */
dialog[open],
[popover]:popover-open {
opacity: 1;
transform: scale(1);
/* 2. Define the starting state for entry (must come after open state) */
@starting-style {
opacity: 0;
transform: scale(0.9);
}
}
/* 3. Define the base (closed/exit) state and transitions */
dialog,
[popover] {
opacity: 0;
transform: scale(0.9);
/* MANDATORY: transition display and overlay for top-layer elements */
transition-property: opacity, transform, display, overlay;
transition-duration: 0.3s;
transition-timing-function: ease-out;
/* Applies to discrete properties like display and overlay */
transition-behavior: allow-discrete; /* Note: be sure to write this after the shorthand */
}
/* 4. Animate the backdrop */
dialog::backdrop,
[popover]::backdrop {
background-color: rgba(0, 0, 0, 0);
transition-property: background-color, display, overlay;
transition-duration: 0.3s;
transition-timing-function: ease-out;
transition-behavior: allow-discrete;
}
dialog[open]::backdrop,
[popover]:popover-open::backdrop {
background-color: rgba(0, 0, 0, 0.5);
@starting-style {
background-color: rgba(0, 0, 0, 0);
}
}
/* 5. Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
dialog,
[popover] {
/* Disable movement and shorten duration for a simple fade */
transform: none;
transition-duration: 0.1s;
}
@starting-style {
dialog[open],
[popover]:popover-open {
transform: none;
}
}
}
제약 조건 및 접근성
- 필수: 최상위 레이어로 이동하거나 최상위 레이어에서 이동하는 모든 요소의
transition목록에overlay를 포함합니다. - 필수:
display속성 전환에allow-discrete를 사용합니다. - 필수: 전환을 간소화하여 (예: 변환 삭제, 지속 시간 단축)
prefers-reduced-motion를 사용한 모션 감소에 대한 사용자 환경설정을 존중합니다. - 권장사항: 적절한 계단식 배열을 위해 '열기' 상태 선택기 내부 또는 뒤에
@starting-style블록을 배치합니다. - 금지사항: 종료 애니메이션에
@starting-style를 사용하지 마세요. 종료 애니메이션은 기본 (닫힘) 상태로의 전환에 의해 정의됩니다.
대체 전략
transition-behavior의 대체 및 브라우저 지원
Firefox 129 이상에서는 display 속성을 실제로 전환하지 않고 transition-behavior: allow-discrete를 파싱하므로(CSS.supports('transition-behavior', 'allow-discrete')는 true를 반환함)(Firefox 버그 1882408) 종료 시 요소가 즉시 사라집니다.
불연속 display 전환 지원을 안정적으로 감지하려면 CSS.supports('transition-behavior', 'allow-discrete')에만 의존하는 대신 none로 전환할 때 임시 요소의 계산된 display가 계속 표시되는지 확인합니다.
let supportsDisplayTransition;
function canTransitionDisplay() {
if (supportsDisplayTransition !== undefined) return supportsDisplayTransition;
if (!window.CSS?.supports?.('transition-behavior', 'allow-discrete') || !document.body) {
return false;
}
const probe = document.createElement('div');
// The shorthand is intentional here: browsers that don't parse allow-discrete
// drop the whole declaration, so display: none applies instantly and the probe
// correctly returns false. !important guards against global reduced-motion
// resets like `* { transition: none !important }`.
probe.style.cssText = 'transition: display 1s allow-discrete !important; display: block;';
document.body.appendChild(probe);
getComputedStyle(probe).display;
probe.style.display = 'none';
supportsDisplayTransition = getComputedStyle(probe).display === 'block';
probe.remove();
return supportsDisplayTransition;
}
상단 레이어 종료 대체
진입 애니메이션은 @starting-style를 지원하는 모든 브라우저에서 순수 CSS로 작동합니다. 진입 전환은 overlay 또는 개별 display 전환에 의존하지 않으므로 .is-opening 클래스가 필요하지 않습니다.
종료 애니메이션에는 overlay 및 개별 display 전환 지원이 모두 필요합니다. 둘 중 하나가 지원되지 않는 경우 (예: Firefox 및 Safari) 열린 상태 선택기를 :is()로 래핑하고 :where(:not([data-closing]))(&::backdrop 및 @starting-style 중첩)를 추가하여 data-closing를 설정하면 요소가 최상위 레이어에 남아 있는 동안 종료 전환이 트리거됩니다. 그런 다음 getAnimations()가 안정화될 때까지 기다린 후 .close() 또는 .hidePopover()를 호출합니다.
:is(dialog[open], [popover]:popover-open):where(:not([data-closing])) {
opacity: 1;
transform: scale(1);
@starting-style {
opacity: 0;
transform: scale(0.9);
}
&::backdrop {
background-color: rgb(0 0 0 / 0.5);
@starting-style {
background-color: transparent;
}
}
}
// Evaluate lazily: canTransitionDisplay() needs document.body, so a top-level
// const would be permanently false if this script runs in <head>.
function supportsTopLayerExit() {
return window.CSS?.supports?.('overlay', 'auto') && canTransitionDisplay();
}
async function closeTopLayer(element) {
if (!supportsTopLayerExit()) {
element.setAttribute('data-closing', '');
const animations = element.getAnimations({ subtree: true });
if (animations.length > 0) {
await Promise.race([
Promise.allSettled(animations.map((a) => a.finished)),
new Promise((r) => setTimeout(r, 2000)),
]);
}
if (!element.hasAttribute('data-closing')) return;
element.removeAttribute('data-closing');
}
element.close();
// Or for popover:
// element.hidePopover();
}
// Route native close requests (Esc, closedby light dismiss) through the same
// helper so they animate too, instead of closing instantly.
dialog.addEventListener('cancel', (event) => {
event.preventDefault();
closeTopLayer(dialog);
});
팝오버 라이트 닫기 및 popovertarget 전환은 가로챌 수 없으므로 (beforetoggle는 열 때만 취소 가능) 대체가 필요한 브라우저에서는 이러한 종료가 즉시 이루어집니다. 종료 애니메이션이 중요한 경우 closeTopLayer()를 호출하는 명시적 닫기 컨트롤을 제공합니다.
Popover의 대체 및 브라우저 지원
Popover API는 대부분 점진적 개선이지만, 최상위 레이어 승격, 가벼운 닫기, popovertarget 호출과 같은 정의 동작에는 CSS 전용에 상응하는 기능이 없습니다. 이전 브라우저에는 폴리필이 필요하며, 폴리필을 제공하지 않으려면 수동 대체가 필요합니다.
폴리필: 이전 브라우저에서 popover 속성을 지원하려면 조건부로 @oddbird/popover-polyfill를 로드합니다. 필수: HTMLElement.prototype에서 popover 속성을 확인하여 기능을 감지하고 네이티브 지원이 누락된 경우에만 폴리필을 로드합니다. 무조건 로드하지 마세요.
번들러 또는 가져오기 맵 사용:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
번들러가 없는 경우 <script type="module"> 내의 CDN에서 가져옵니다.
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
스타일 지정 주의사항: 폴리필은 실제 :popover-open 의사 클래스를 정의할 수 없으므로 대신 .\:popover-open 클래스를 적용합니다. 필수: :is() 또는 :where()를 사용하여 두 가지를 결합합니다. 그렇지 않으면 :popover-open가 없는 브라우저에서 전체 규칙을 삭제합니다.
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
또는 폴리필이 없는 기존 대체 기능의 경우 position: fixed를 사용하고 getBoundingClientRect()를 통해 좌표를 수동으로 계산하거나, 사용 사례에 적합한 경우 inset: auto를 사용하여 기본 위치 지정에 의존합니다.