@starting-style로 팝오버와 대화상자에 등장·퇴장 애니메이션 넣기

@starting-style과 transition-behavior: allow-discrete로 팝오버·대화상자 등장·퇴장 애니메이션을 CSS만으로 만드는 방법

10/10/2026

css

조회수 2

문제 배경

팝오버나 모달을 열 때 살짝 페이드인 효과를 주고 싶어서 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);
  }
}

동작 순서를 정리하면 다음과 같습니다.

  1. 열 때: display가 0% 지점에서 바로 바뀌어 요소가 보이고, @starting-style의 값에서 :popover-open의 값으로 transition합니다.
  2. 닫을 때: :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