logo
Nostrss
Published on

display: none 을 transition 하기 — allow-discrete 와 @starting-style (Transition to and from display: none)

Authors

Transition to and from display: none

출처: Transition to and from display: none — Kevin Powell. 영상의 내용을 기록으로 남겨두기 위해 ai로 번역해서 블로그를 작성했다.

문제 — display 는 discrete 속성이라 transition 이 안 된다

모달이나 드롭다운을 열고 닫을 때 부드럽게 페이드 인/아웃 시키고 싶다. 그런데 display: nonedisplay: grid 로 바뀌는 요소에는 transition: opacity 2s 를 아무리 걸어도 동작하지 않는다.

이유는 displaydiscrete(이산) 속성이기 때문이다. opacity 처럼 0 과 1 사이의 중간값이 존재하는 속성과 달리, displaynone 아니면 grid 다. 중간 상태가 없으니 브라우저는 그냥 즉시 값을 바꿔버리고, 그 순간 요소가 사라지므로 함께 걸어둔 opacity transition 도 보일 틈이 없다.

영상에서는 <dialog> 를 invoker commands (command/commandfor 속성) 로 열고 닫는 예제를 쓰는데, invoker commands 는 모든 브라우저에 들어갔지만 아직 신생이라 불안하면 JavaScript 로 열어도 똑같이 동작한다. 그리고 dialog 만의 이야기가 아니다 — display: none 에서 다른 display 값으로 전환되는 모든 요소에 해당한다.

아래 코드는 화면을 직접 본 것이 아니라 자막을 기반으로 재구성한 예시다.

해결 1 — transition-behavior: allow-discrete

첫 번째 한 줄. discrete 속성도 transition 대상에 포함시키겠다고 선언한다.

dialog {
  transition:
    opacity 2s,
    display 2s;
  transition-behavior: allow-discrete;
}

이렇게 하면 닫힐 때 페이드아웃이 동작한다. transition 이 도는 2초 동안 displaygrid 로 유지되다가, 다른 속성들의 transition 이 끝난 뒤에 none 으로 바뀌기 때문이다.

하지만 열릴 때는 여전히 즉시 나타난다. display: none 상태의 요소는 렌더링 자체가 안 되어 있어서, grid 가 되는 순간 "이전 상태" 라는 게 없다. 브라우저 입장에서는 출발점을 모르니 transition 할 수가 없다.

해결 2 — @starting-style

두 번째 한 줄. 요소가 처음 렌더링될 때의 시작 상태를 지정하는 새 at-rule 이다.

dialog[open] {
  opacity: 1;

  @starting-style {
    opacity: 0;
  }
}

이제 열릴 때는 opacity: 0 에서 출발해 페이드인되고, 닫힐 때는 allow-discrete 덕에 페이드아웃된다. 양방향 완성이다.

순서가 중요하다

@starting-styleopen 상태 규칙 뒤에 와야 한다. cascade 에서 마지막 규칙이 이기기 때문에, 앞에 두면 open 상태 스타일이 시작 스타일을 덮어써서 동작하지 않는다. media query 와 똑같은 원리다.

/* 1. 닫힌 상태 (기본) */
dialog { opacity: 0; transition: ...; transition-behavior: allow-discrete; }

/* 2. 열린 상태 */
dialog[open] { opacity: 1; }

/* 3. 닫힘 → 열림 전환의 시작점 — 반드시 2번 뒤에 */
dialog[open] {
  @starting-style { opacity: 0; }
}

순서가 직관적이지 않으니 주석을 남겨두는 게 좋다.

진입과 퇴장 애니메이션을 다르게

@starting-style 의 진짜 재미는 여기다. 시작 상태와 끝 상태를 따로 지정할 수 있으니 들어올 때와 나갈 때 움직임을 다르게 만들 수 있다.

dialog {
  translate: 0 50px; /* 닫힐 때: 아래로 내려가며 사라짐 */
  transition:
    opacity 2s,
    translate 2s,
    display 2s,
    overlay 2s;
  transition-behavior: allow-discrete;
}

dialog[open] {
  translate: 0 0;

  @starting-style {
    opacity: 0;
    translate: 0 -50px; /* 열릴 때: 위에서 내려오며 등장 */
  }
}

위에서 내려오며 나타나고, 아래로 빠지며 사라진다. transition 대상에 overlay 도 넣었는데, dialog 와 popover 는 top layer 에서 렌더링되기 때문에 top layer 에서 빠지는 시점도 transition 이 끝날 때까지 붙잡아 두려는 것이다.

backdrop 도 같이 페이드

::backdrop 은 그냥 두면 즉시 나타났다 즉시 사라진다. 가장 쉬운 방법은 opacity 만 갖고 노는 것이다.

dialog::backdrop {
  opacity: 0;
  transition: opacity 2s;
}

dialog[open]::backdrop {
  opacity: 1;
}

opacity 가 서서히 변하면서 backdrop 의 어두운 필터도 함께 부드럽게 전환된다.

언제 쓰면 좋을까

  • 모달 / 다이얼로그<dialog> + show-modal invoker command 조합에 그대로 얹으면 JavaScript 애니메이션 라이브러리 없이 진입·퇴장 애니메이션이 된다.
  • popover, 드롭다운 메뉴, 토스트display: none 으로 숨기는 모든 UI. popover 도 top layer 를 쓰므로 overlay transition 까지 같은 패턴이 적용된다.
  • 기존에 JS 로 클래스 토글 + setTimeout 으로 display 를 지연 제거하던 코드 — 그 해킹이 하던 일이 정확히 allow-discrete 가 하는 일이다. CSS 두 줄로 대체할 수 있다.

정리

  • display 는 discrete 속성이라 기본적으로 transition 이 안 된다 — 중간값이 없어 즉시 바뀐다.
  • transition-behavior: allow-discrete 는 transition 이 끝날 때까지 display 값을 붙잡아 줘서 닫힐 때 애니메이션을 가능하게 한다.
  • @starting-style 은 요소가 처음 렌더링될 때의 시작 상태를 지정해 열릴 때 애니메이션을 가능하게 한다 — open 상태 규칙보다 뒤에 써야 한다 (cascade).
  • 시작 상태와 퇴장 상태를 다르게 주면 진입·퇴장 애니메이션을 각각 디자인할 수 있고, ::backdrop 은 opacity transition 으로 함께 처리한다.