Ayden's journal

View Transitions API

CSS

웹 애플리케이션의 사용자 경험은 상태 전환의 자연스러움에 크게 좌우된다. 사용자가 리스트에서 항목을 선택해 상세로 들어가거나, 카드가 화면에서 커다란 히어로 이미지로 확장되는 장면이 부드럽게 이어지면 인터페이스는 훨씬 더 신뢰감을 준다. 과거에는 이런 효과를 만들려면 복잡한 DOM 측정·계산·동기화 로직을 직접 구현해야 했지만, View Transitions API는 브라우저 수준에서 '상태 변경을 알려주면 알아서 전환을 처리'하는 패러다임을 제시한다.

전통적인 전환은 개발자가 이전 상태와 이후 상태를 둘 다 직접 다루고, 애니메이션 타이밍을 맞추며, 엔터/엑시트 애니메이션을 수동으로 제어해야 했다. View Transitions API는 이 과정을 단순화한다. 개발자는 "이제 상태를 바꾼다"는 사실만 브라우저에 알리면 되고, 브라우저는 변경 전/후 스냅샷을 찍어 두 스냅샷을 비교·보간하여 자연스러운 전환을 실행한다. 실제 사용은 간단하다. DOM 변경을 document.startViewTransition()의 콜백으로 감싸기만 하면 된다.

document.startViewTransition(() => {
  // 상태를 바꾸는 모든 DOM 업데이트를 이 안에 넣는다.
  document.body.classList.add('grid-layout');
  renderDetailsFor(id);
});

이 방식의 장점은 복잡한 애니메이션 동기화 로직을 줄이고, 브라우저가 가능한 최적의 방법(스냅샷 캡처, 합성 레이어 사용 등)을 선택하게 할 수 있다는 점이다. 개발자는 의미 있는 '연결(transition)'만 지정하면 되고, 브라우저는 위치·크기·불투명도 같은 속성을 보간해 매끄럽게 처리한다.

 

view-transition-name과 의사 요소 트리

View Transitions API의 핵심 기능은 서로 다른 문서 상태에 존재하는 요소들을 연결해 '같은 객체가 이동·확대·변형되는 것처럼' 보이게 하는 것이다. 이 연결은 view-transition-name으로 지정된다. 같은 이름을 가진 요소는 전환 과정에서 매칭되어 위치·크기·모양 보간이 적용된다. 따라서 목록의 작은 썸네일이 상세 페이지의 큰 히어로 이미지로 자연스럽게 확장되게 하려면 두 상태의 해당 요소에 동일한 view-transition-name을 설정하면 된다.

<!-- 목록 -->
<img src="cover.jpg" alt="표지" style="view-transition-name: book-cover" />

<!-- 상세 -->
<img src="cover.jpg" alt="표지 (상세)" style="view-transition-name: book-cover" />

브라우저는 내부적으로 전환용 의사 요소(pseudo-element) 트리를 생성하여 ::view-transition-old(...), ::view-transition-new(...) 같은 선택자를 통해 전환 시 스타일을 제어할 수 있게 한다. 이를 활용하면 기본 cross-fade 대신 슬라이드·스케일·마스크 등 다양한 효과를 CSS만으로 적용할 수 있다.

전환을 설계할 때 주의할 점은 view-transition-name은 전환 시점의 렌더링 문서 내에서 고유해야 한다는 것이다. 동일 이름이 여러 요소에 부여되어 있으면 브라우저가 어느 요소를 매칭해야 할지 판단할 수 없어 전환을 취소하거나 예기치 않은 동작이 발생할 수 있다. 또한 요소가 전환 시점(스냅샷 캡처 시)에 실제로 존재해야 하므로 렌더 타이밍과의 불일치에 주의해야 한다.

 

CSS 의사 요소로 전환 효과 커스터마이징

브라우저가 View Transitions API를 실행하면 실제 DOM 요소를 직접 움직이지 않고, 그 대신 전환에 필요한 스냅샷을 담은 의사 요소 트리를 생성한다. 개발자는 이 의사 요소를 대상으로 CSS를 작성하여 전환 애니메이션을 원하는 방식으로 커스터마이징할 수 있다. 생성되는 의사 요소 트리는 아래와 같은 구조를 가진다.

::view-transition
└─ ::view-transition-group(<pt-name-selector>[.<pt-class-selector>]?)
   ├─ ::view-transition-image-pair(<pt-name-selector>[.<pt-class-selector>]?)
   │  ├─ ::view-transition-old(<pt-name-selector>[.<pt-class-selector>]?)
   │  └─ ::view-transition-new(<pt-name-selector>[.<pt-class-selector>]?)
   └─ ... (다른 그룹들)

여기서 <pt-name-selector>는 view-transition-name 속성으로 지정한 고유 값으로, 특정 전환 요소를 다른 요소와 구분하기 위해 사용된다. 반면, <pt-class-selector>는 view-transition-class 속성으로 지정한 값으로, 여러 곳에서 같은 스타일을 재사용하고 싶을 때 유용하다.

따라서 특정 요소 하나를 고유하게 지정하고 싶다면 <pt-name-selector>를 사용하고, 여러 요소에 공통 스타일을 적용하고 싶다면 <pt-class-selector>를 사용하게 된다. 예를 들어, ::view-transition-group(card.featured)처럼 이름과 클래스를 함께 조합할 수도 있고, ::view-transition-group(.featured)처럼 클래스만 지정하여 선택할 수도 있다.

 

이 의사 요소들은 기본적으로 브라우저가 제공하는 cross-fade 전환을 수행한다. 그러나 CSS 애니메이션을 직접 작성하면 기본 효과를 대체할 수 있다. 예를 들어 화면이 전환될 때 단순한 페이드 대신 좌우 슬라이드 애니메이션을 적용할 수도 있다.

아래 코드에서는 이전 뷰를 왼쪽으로 밀어내면서 사라지게 하고, 새로운 뷰를 오른쪽에서 슬라이드되어 들어오도록 정의했다. 이처럼 의사 요소에 원하는 애니메이션을 할당하면, 전환 효과를 자유롭게 커스터마이징할 수 있다.

/* 최상위 전환 컨테이너 */
::view-transition {
  isolation: isolate;
}

/* root 그룹을 대상으로 전환 애니메이션을 지정 */
::view-transition-group(root) {
  overflow: hidden;
}

/* root 그룹의 이미지 쌍 */
::view-transition-image-pair(root) {
  mix-blend-mode: normal;
}

/* 이전 뷰 (사라지는 스냅샷) */
::view-transition-old(root) {
  animation: slide-out 0.3s ease-in-out forwards;
}

/* 새로운 뷰 (나타나는 스냅샷) */
::view-transition-new(root) {
  animation: slide-in 0.3s ease-in-out forwards;
}

/* keyframes 정의 */
@keyframes slide-out {
  from {
    transform: translateX(0);
    opacity: 1;
  }
  to {
    transform: translateX(-40px);
    opacity: 0;
  }
}

@keyframes slide-in {
  from {
    transform: translateX(40px);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

 

React에서의 View Transitions API

웹 애플리케이션에서 부드러운 페이지 전환은 사용자 경험의 핵심 요소 중 하나이다. 리스트에서 항목을 선택해 상세 페이지로 넘어가거나, 화면 내 컴포넌트가 자연스럽게 변형되는 장면은 인터페이스의 품질을 결정짓는다. View Transitions API는 브라우저 수준에서 이러한 전환을 자동으로 처리해주는 혁신적인 기능을 제공하지만, React와 같은 선언적 UI 라이브러리에서는 몇 가지 까다로운 문제가 발생한다.

특히 React 18 이후 도입된 동시성 렌더링 모델에서는 상태 업데이트가 비동기적으로 배치(batch) 처리되기 때문에, 전환과 DOM 업데이트 간의 타이밍 불일치 문제가 흔히 발생한다. 예를 들어 document.startViewTransition 안에서 단순히 setState를 호출하면, 브라우저가 ‘변경 후’ 스냅샷을 찍기 전에 React가 DOM을 갱신하지 않아 예상치 못한 전환 동작이 발생할 수 있다.

이 문제를 해결하기 위해 React는 flushSync라는 API를 제공한다. flushSync로 상태 업데이트를 감싸면, 해당 업데이트가 즉시 DOM에 반영되어 스냅샷이 정확하게 찍히게 된다.

import React, { useState } from 'react';
import { flushSync } from 'react-dom';
import './App.css'; // CSS에 view-transition-name 지정

const items = [
  { id: 1, name: '책 1' },
  { id: 2, name: '책 2' },
  { id: 3, name: '책 3' },
];

export default function App() {
  const [selectedId, setSelectedId] = useState(null);

  const handleClick = (id) => {
    if (!document.startViewTransition) {
      setSelectedId(id);
      return;
    }

    document.startViewTransition(() => {
      // flushSync로 상태 업데이트를 즉시 DOM에 반영
      flushSync(() => setSelectedId(id));
    });
  };

  const handleBack = () => {
    if (!document.startViewTransition) {
      setSelectedId(null);
      return;
    }

    document.startViewTransition(() => {
      flushSync(() => setSelectedId(null));
    });
  };

  return (
    <div className="app-container">
      {!selectedId ? (
        <div className="list">
          {items.map((item) => (
            <div
              key={item.id}
              className="list-item"
              style={{ viewTransitionName: `item-${item.id}` }}
              onClick={() => handleClick(item.id)}
            >
              {item.name}
            </div>
          ))}
        </div>
      ) : (
        <div className="detail" style={{ viewTransitionName: `item-${selectedId}` }}>
          <h2>상세 보기: {items.find((i) => i.id === selectedId).name}</h2>
          <button onClick={handleBack}>뒤로가기</button>
        </div>
      )}
    </div>
  );
}
/* 기본 cross-fade 효과 */
::view-transition-old(*) {
  opacity: 1;
  transition: opacity 0.3s ease-in-out;
}

::view-transition-new(*) {
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}

::view-transition-new(*) {
  opacity: 1;
}

 

선언적 ViewTransitions 컴포넌트

React에서 전환 애니메이션을 구현할 때, flushSync를 사용하면 기술적으로는 동작하지만 심각한 성능상의 단점이 존재한다. flushSync는 메인 스레드를 차단하고 상태가 렌더링될 때까지 기다리기 때문에, React의 동시성 렌더링이 제공하는 장점을 완전히 무시한다. 복잡한 컴포넌트 구조나 저사양 환경에서는 전환 중 버벅임(jank)이 발생할 수 있으며, 장기적으로는 유지보수 측면에서도 최적의 방법이 아니다.

이 문제를 해결하기 위해 React 팀은 선언적 <ViewTransitions> 컴포넌트라는 접근법을 제시했다. React의 <ViewTransition> 컴포넌트는 전환 애니메이션을 선언적으로 적용할 수 있도록 설계된 실험적 기능이다. 이 컴포넌트는 각 요소 단위로 전환 참여를 표시하는 역할을 하며, 개발자가 직접 document.startViewTransition()을 호출할 필요 없이, React가 내부적으로 전환 시점을 관리한다. 즉, <ViewTransition>은 DOM 스냅샷을 올바른 시점에 캡처하도록 보장하고, 애니메이션이 매끄럽게 적용될 수 있도록 돕는다.

따라서 실제 애플리케이션에서는 전환 애니메이션을 적용하고 싶은 각 요소에 <ViewTransition>을 개별적으로 선언해야 한다. 예를 들어 글 제목, 작성자 아바타, 게시 날짜 등과 같은 주요 UI 요소마다 name prop을 지정하면, React는 해당 요소를 전환 대상으로 포함하여 브라우저 수준의 스냅샷과 애니메이션 처리를 수행한다. 이 방식 덕분에 개발자는 flushSync 같은 강제 동기 렌더링을 쓰지 않아도 되며, 선언적 API를 통해 코드의 가독성과 유지보수성을 높일 수 있다.

 

Next.js 환경에서의 ViewTransition 예제는 이쪽 링크에서 확인할 수 있고, 리액트의 ViewTransition 컴포넌트 공식 문서는 이쪽이다.

 

아래는 ViewTransition 컴포넌트가 받는 Props의 타입이다. 이전에는 view-transition-class를 할당하기 위해 className이라는 이름의 프로퍼티를 사용했는데, 클래스 선택자와 혼동될 것을 우려했는지 default로 이름이 바뀌었다. 그 외에도 여러 프로퍼티가 있지만 눈여겨볼만한 것은 name 프로퍼티와 enter / exit / share / update 프로퍼티이다.

export interface ViewTransitionProps {
  children?: ReactNode | undefined;
  default?: ViewTransitionClass | undefined;
  enter?: ViewTransitionClass | undefined;
  exit?: ViewTransitionClass | undefined;
  name?: "auto" | (string & {}) | undefined;
  onEnter?: (instance: ViewTransitionInstance, types: Array<string>) => void;
  onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void;
  onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void;
  onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void;
  ref?: Ref<ViewTransitionInstance> | undefined;
  share?: ViewTransitionClass | undefined;
  update?: ViewTransitionClass | undefined;
}

 

enter, exit, update 프로퍼티는 React 컴포넌트의 생애주기와 연동되어 동작하는 전환 클래스이다. enter는 <ViewTransition>이 마운트될 때 자동으로 적용되어, 새로 나타나는 요소의 진입 애니메이션을 정의한다. 반대로 exit는 컴포넌트가 언마운트될 때 적용되어, 요소가 사라질 때의 전환 효과를 처리한다. update는 내부 콘텐츠가 변경되거나 자식 <ViewTransition>의 크기나 위치가 변할 때 적용되며, DOM 구조는 유지되지만 레이아웃 변화에 따른 애니메이션을 구현할 수 있다.

 

share 프로퍼티는 동일한 name을 가진 다른 <ViewTransition> 인스턴스와 공유 요소 전환(shared element transition)을 수행할 때 적용되는 클래스이다. 하지만 마운트된 쪽이나 언마운트된 쪽 중 하나가 뷰포트 밖에 위치하면 쌍이 형성되지 않는다. 이는 스크롤 시 요소가 화면 밖으로 날아가면서 의도치 않게 전환이 발생하는 것을 방지하기 위한 동작이다. 이런 경우에는 해당 요소는 공유 전환 대신 일반적인 enter 혹은 exit 애니메이션으로 처리된다.

반대로 동일한 컴포넌트 인스턴스가 위치만 변경되는 경우에는 update 전환이 발생하며, 뷰포트 밖에 위치해 있어도 애니메이션이 적용된다. 특이하게도, 깊게 중첩된 마운트 해제된 <ViewTransition> 요소가 뷰포트 안에 있고, 대응하는 마운트되는 요소가 뷰포트 밖에 있는 경우, 해당 요소는 부모 공유 애니메이션의 일부로 처리되지 않고 자체적인 exit 애니메이션으로 동작한다. 이렇게 하면 중첩 구조에서도 전환이 일관되게 적용되도록 보장된다.

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기