이전에는 CSS 전환이 DOM에 처음 추가되거나 display 속성이 none에서 변경될 때 요소에 애니메이션을 적용할 수 없었습니다. @starting-style at-rule 및 transition-behavior: allow-discrete는 부드러운 시작 및 종료 애니메이션을 만드는 선언적 방법을 제공합니다.
구현
1. display: none 전환 버튼 애니메이션 처리
속성 (예: display: none가 있는 hidden)을 통해 공개 상태를 전환할 때 요소에 애니메이션을 적용하려면 다음 단계를 따르세요.
- 표시 상태 정의: 기본 클래스에서 최종 속성 값 (예:
opacity: 1)을 설정합니다. - 항목 시작 상태 정의:
@starting-style를 사용하여 요소가 표시될 때 전환할 값을 지정합니다. - 불연속 전환 사용 설정:
transition속성에display를 포함하고transition-behavior: allow-discrete를 사용합니다. - 종료 상태 정의:
hidden속성에서 타겟 값을 설정합니다.
.card {
display: block;
opacity: 1;
translate: 0;
/* MANDATORY: Use transition-behavior: allow-discrete for display transition */
transition:
display 0.4s,
opacity 0.4s ease-out,
translate 0.4s ease-out;
transition-behavior: allow-discrete;
}
/* Entry animation: transition FROM these values when first rendered */
@starting-style {
.card {
opacity: 0;
translate: 0 -20px;
}
}
/* Exit animation: transition TO these values when hidden */
.card[hidden] {
display: none;
opacity: 0;
translate: 0 -20px;
}
/* Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
.card {
/* Disable movement and shorten duration for a simple fade */
translate: none;
transition-duration: 0.1s;
}
@starting-style {
.card {
translate: none;
}
}
.card[hidden] {
translate: none;
}
}
2. DOM 삽입 및 삭제 애니메이션
appendChild()를 통해 추가되거나 remove()를 통해 삭제된 요소의 경우:
- 항목: 위와 같이
@starting-style를 사용합니다. 브라우저는 '없음'에서 요소의 초기 스타일로의 스타일 변경을 자동으로 감지하고@starting-style값에서 전환을 트리거합니다. - 삭제:
element.remove()는 즉시 실행되며 자체적으로 CSS 전환을 트리거하지 않으므로 DOM에서 노드를 삭제하기 전에 먼저 종료 전환을 트리거하고 완료될 때까지 기다려야 합니다.
// 1. Trigger exit transition
element.hidden = true;
// 2. Wait for all active transitions/animations to finish,
// with a failsafe timeout in case an animation never ends (e.g. for looping animations)
const animations = element.getAnimations();
if (animations.length > 0) {
await Promise.race([
// Promise.allSettled ensures we wait even if some animations fail
Promise.allSettled(animations.map(a => a.finished)),
new Promise(r => setTimeout(r, 2000))
]);
}
// 3. Finally remove the node from the DOM
element.remove();
제약 조건 및 접근성
- 필수:
display전환 시transition-behavior: allow-discrete를 사용합니다. 이 속성이 없으면 종료 중에 요소가 즉시 사라집니다. transition약식에allow-discrete를 사용하지 마세요. 이전 브라우저에서 전체transition선언을 무시하게 됩니다. 이러한 사용 사례가 바람직한 경우를 제외하고 별도의transition-behavior: allow-discrete선언을 사용하세요.- 필수: 시작 애니메이션에
@starting-style를 사용합니다. 브라우저는 이 값이 제공되지 않는 한 요소의 첫 번째 스타일 업데이트 (초기 렌더링 또는display: none변경)에서 전환을 건너뜁니다. - 권장사항:
<dialog>또는popover와 같은 최상위 레이어 요소에 애니메이션을 적용하는 경우 종료 애니메이션 중에 최상위 레이어에 유지되도록transition목록에overlay를 포함합니다. - 권장사항:
prefers-reduced-motion미디어 쿼리를 사용하여 모션 감소에 대한 사용자 환경설정을 존중합니다. - 금지사항: 종료 애니메이션에
@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;
}
불연속 display 전환이 지원되지 않는 경우 대체 종료
@starting-style를 사용하는 시작 애니메이션은 JavaScript 없이 모든 최신 브라우저에서 작동합니다. 불연속 display 전환이 지원되지 않는 경우 (!canTransitionDisplay()) hidden를 설정하면 display: none가 즉시 적용되고 종료 애니메이션이 건너뛰어집니다 (숨길 때와 element.remove() 전 모두). 시각적 종료 상태 ([data-closing])를 display: none ([hidden])에서 분리하여 opacity 및 translate가 요소를 숨기거나 삭제하기 전에 애니메이션을 완료하도록 합니다. 1단계의 .card[hidden] 규칙(prefers-reduced-motion 재정의 포함)을 다음으로 바꿉니다.
.card:where([hidden], [data-closing]) {
opacity: 0;
translate: 0 -20px;
}
.card[hidden] {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.card:where([hidden], [data-closing]) {
translate: none;
}
}
async function hideElement(el) {
if (canTransitionDisplay()) {
el.hidden = true;
} else {
el.setAttribute('data-closing', '');
}
const animations = el.getAnimations();
if (animations.length > 0) {
await Promise.race([
Promise.allSettled(animations.map((a) => a.finished)),
new Promise((r) => setTimeout(r, 2000)),
]);
}
// Skipped if showElement() cancelled the close mid-animation.
if (el.hasAttribute('data-closing')) {
el.removeAttribute('data-closing');
el.hidden = true;
}
}
function showElement(el) {
el.removeAttribute('data-closing');
el.hidden = false;
}
async function removeElement(el) {
await hideElement(el);
el.remove();
}