요소 들어가기 및 나가기 애니메이션

이전에는 CSS 전환이 DOM에 처음 추가되거나 display 속성이 none에서 변경될 때 요소에 애니메이션을 적용할 수 없었습니다. @starting-style at-rule 및 transition-behavior: allow-discrete는 부드러운 시작 및 종료 애니메이션을 만드는 선언적 방법을 제공합니다.

구현

1. display: none 전환 버튼 애니메이션 처리

속성 (예: display: none가 있는 hidden)을 통해 공개 상태를 전환할 때 요소에 애니메이션을 적용하려면 다음 단계를 따르세요.

  1. 표시 상태 정의: 기본 클래스에서 최종 속성 값 (예: opacity: 1)을 설정합니다.
  2. 항목 시작 상태 정의: @starting-style를 사용하여 요소가 표시될 때 전환할 값을 지정합니다.
  3. 불연속 전환 사용 설정: transition 속성에 display를 포함하고 transition-behavior: allow-discrete를 사용합니다.
  4. 종료 상태 정의: 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();
}