View Transition API

작성일:2026.02.22|수정일:2026.07.07|조회수:12

View Transition API

화면 전환은 늘 애매한 영역이었다. 기능 명세에는 잘 안 들어가지만, 막상 없으면 서비스가 묘하게 둔하게 느껴진다. 목록에서 상세로 들어갈 때, 필터를 바꿨을 때, 카드가 확장될 때 화면이 자연스럽게 이어지면 사용자는 “빠르다”고 느낀다. 반대로 데이터는 이미 바뀌었는데 화면만 툭 끊기면, 실제 성능과 별개로 어딘가 불안해 보인다.

그렇다고 전환을 직접 잘 만드는 일이 쉬웠던 것도 아니다. DOM 위치를 재고, 이전 좌표와 다음 좌표를 계산하고, enter/exit 상태를 맞추고, 중간에 스크롤이나 레이아웃이 바뀌면 다시 보정한다. 처음에는 작은 애니메이션 하나였는데 시간이 지나면 requestAnimationFrame, ResizeObserver, z-index, 포커스 처리, 언마운트 타이밍이 뒤섞인 작은 늪이 된다. 나는 이런 코드를 볼 때마다 “이 정도까지 해야 하나”라는 생각을 자주 했다.

View Transition API는 이 문제를 조금 다르게 본다. 개발자가 이전 화면과 다음 화면의 좌표를 직접 이어 붙이는 대신, 브라우저에게 “지금부터 화면 상태가 바뀐다”고 알려준다. 그러면 브라우저가 변경 전후의 스냅샷을 만들고, 그 스냅샷 사이를 전환 전용 레이어에서 애니메이션한다. 개발자는 전환의 경계를 지정하고, 어떤 요소가 같은 의미를 갖는지 이름을 붙이고, 필요한 경우 CSS로 움직임을 조정한다.

전환의 책임이 꽤 많이 브라우저로 넘어가는 셈이다. 물론 그래서 모든 문제가 사라지는 것은 아니다. 이름 충돌, 브라우저 지원, 접근성, 렌더링 타이밍 같은 새 문제가 생긴다. 그래도 적어도 “전환을 만들기 위해 레이아웃 엔진을 흉내 내는 코드”를 줄일 수 있다는 점은 꽤 크다.

startViewTransition의 기본 모델

동일 문서 안에서 상태가 바뀌는 전환은 document.startViewTransition()으로 시작한다. 이 메서드는 현재 화면을 캡처하고, 콜백 안에서 DOM 업데이트를 실행한 뒤, 업데이트 이후 화면을 다시 캡처한다. 그리고 브라우저가 두 상태 사이를 애니메이션한다.

JS
function updateLayout() {
  const app = document.querySelector('#app');
  if (!app) return;

  app.classList.toggle('is-detail');
}

export function transitionLayout() {
  if (!document.startViewTransition) {
    updateLayout();
    return;
  }

  document.startViewTransition(() => {
    updateLayout();
  });
}

여기서 폴백은 거의 필수라고 생각한다. View Transition은 기능의 전제 조건이 아니라 점진적 향상의 층이어야 한다. 지원하지 않는 브라우저에서도 화면은 바뀌어야 하고, 전환만 빠져야 한다. 애니메이션이 실패했다고 기능까지 실패하면 배보다 배꼽이 커진다.

startViewTransition()ViewTransition 객체를 반환한다. 이 객체에는 전환 생명주기를 볼 수 있는 ready, finished, updateCallbackDone 같은 프로미스가 있다. 실무에서는 특히 readyfinished를 자주 보게 된다.

JS
export function runViewTransition(updateDOM) {
  if (!document.startViewTransition) {
    updateDOM();
    return;
  }

  const transition = document.startViewTransition(() => {
    updateDOM();
  });

  transition.ready
    .then(() => {
      console.info('[view-transition] ready');
    })
    .catch((error) => {
      console.warn('[view-transition] ready rejected', error);
    });

  transition.finished.then(() => {
    console.info('[view-transition] finished');
  });
}

ready는 브라우저가 전환을 시작할 준비를 마쳤을 때 resolve된다. 같은 view-transition-name이 동시에 여러 개 존재하는 등 전환을 구성할 수 없는 상황에서는 reject될 수 있다. finished는 애니메이션이 끝난 뒤 resolve된다. 전환이 끝난 다음 포커스를 이동하거나, 임시 클래스를 제거하거나, 분석 이벤트를 보내야 한다면 이 시점이 더 안전하다.

중요한 것은 콜백을 얇게 유지하는 것이다. 콜백 안에서 무거운 계산을 하거나 네트워크 응답을 기다리면, 브라우저가 이전 스냅샷과 다음 스냅샷 사이에서 멈춰 있는 시간이 길어진다. 전환이 부드러워지는 것이 아니라 그냥 늦게 반응하는 UI가 된다.

JS
const prepared = prepareNextState();

document.startViewTransition(() => {
  applyDOM(prepared);
});

전환 콜백은 “이미 준비된 상태를 DOM에 반영하는 경계” 정도로 두는 편이 낫다.

view-transition-name과 동일성

기본 전환은 전체 화면의 크로스 페이드다. 이것만으로도 화면이 툭 끊기는 느낌은 줄어든다. 하지만 View Transition API의 재미있는 부분은 shared element 전환이다. 목록의 작은 카드가 상세 화면의 큰 카드로 이어지는 것처럼, 전환 전후의 요소를 같은 대상으로 연결할 수 있다.

이때 쓰는 속성이 view-transition-name이다.

HTML
<!-- 목록 화면 -->
<img
  src="/books/clean-code.jpg"
  alt="Clean Code 표지"
  style="view-transition-name: book-cover-42"
/>

<!-- 상세 화면 -->
<img
  src="/books/clean-code.jpg"
  alt="Clean Code 표지"
  style="view-transition-name: book-cover-42"
/>

브라우저는 전환 전후 화면에서 같은 view-transition-name을 가진 요소를 하나의 의미 있는 대상으로 본다. 위치와 크기가 달라도, “이것은 같은 책 표지다”라는 연결이 생긴다. 그래서 전환은 단순한 페이드가 아니라 이동과 크기 변화가 섞인 연속적인 변화처럼 보인다.

주의할 점은 이름이 전환 시점에 고유해야 한다는 것이다. 같은 화면에 book-cover-42가 두 개 있으면 브라우저가 어느 요소를 연결해야 할지 알 수 없다. 이 경우 전환이 스킵되거나 ready가 reject될 수 있다. 그래서 실무에서는 도메인 ID를 섞어 이름을 만드는 편이 안전하다.

TSX
function BookCard({ book }: { book: { id: string; title: string; cover: string } }) {
  return (
    <article style={{ viewTransitionName: `book-card-${book.id}` }}>
      <img src={book.cover} alt={`${book.title} 표지`} />
      <h2>{book.title}</h2>
    </article>
  );
}

이름은 동일성의 기준이다. 디자인 토큰처럼 대충 공통 이름을 붙이는 순간 충돌이 난다. “이 요소가 전환 전후에 무엇과 이어져야 하는가”를 기준으로 이름을 붙여야 한다.

의사 요소와 view-transition-class

전환이 시작되면 브라우저는 전환 전용 의사 요소 트리를 만든다. 대략 이런 구조로 생각하면 된다.

TXT
::view-transition
└─ ::view-transition-group(<name>[.<class>]?)
   └─ ::view-transition-image-pair(<name>[.<class>]?)
      ├─ ::view-transition-old(<name>[.<class>]?)
      └─ ::view-transition-new(<name>[.<class>]?)

old는 이전 스냅샷, new는 다음 스냅샷이다. CSS는 이 의사 요소를 대상으로 애니메이션을 지정한다. 예를 들어 전체 화면 전환을 살짝 좌우로 밀 수 있다.

CSS
::view-transition-group(root) {
  overflow: hidden;
  animation-duration: 260ms;
}

::view-transition-old(root) {
  animation: slide-out 260ms ease both;
}

::view-transition-new(root) {
  animation: slide-in 260ms ease both;
}

@keyframes slide-out {
  from {
    opacity: 1;
    transform: translateX(0);
  }

  to {
    opacity: 0;
    transform: translateX(-32px);
  }
}

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

  to {
    opacity: 1;
    transform: translateX(0);
  }
}

개별 요소마다 이름을 다르게 붙이면 매칭은 안정적이지만, 공통 스타일을 적용할 때 선택자가 길어진다. 이때 view-transition-class를 쓸 수 있다. view-transition-name이 “무엇과 무엇을 연결할지”를 정한다면, view-transition-class는 “어떤 전환 스타일 묶음에 속하는지”를 정한다.

CSS
.card-a {
  view-transition-name: card-a;
  view-transition-class: card-featured;
}

.card-b {
  view-transition-name: card-b;
  view-transition-class: card-featured;
}

::view-transition-group(.card-featured) {
  animation-duration: 280ms;
}

::view-transition-old(.card-featured),
::view-transition-new(.card-featured) {
  border-radius: 16px;
}

여기서 헷갈리면 안 된다. 매칭은 view-transition-name으로 한다. view-transition-class는 매칭 키가 아니라 스타일 훅이다. 이 둘을 분리해서 생각하면 전환 설계가 훨씬 덜 복잡해진다.

SPA 내부 전환

SPA에서 View Transition을 적용하는 가장 흔한 장면은 같은 문서 안에서 상태만 바뀌는 경우다. 목록에서 상세로 들어가거나, 탭을 바꾸거나, 필터 결과가 재배치되는 식이다.

리액트 같은 프레임워크에서는 상태 업데이트가 실제 DOM 커밋으로 이어지는 시점을 조심해야 한다. 브라우저는 콜백 전후의 DOM을 캡처하는데, 프레임워크가 업데이트를 나중으로 미루면 캡처 타이밍과 실제 변경 시점이 어긋날 수 있다.

리액트에서는 필요한 구간에 한해 flushSync를 사용할 수 있다.

TSX
import { useState } from 'react';
import { flushSync } from 'react-dom';

type Book = {
  id: number;
  title: string;
};

const books: Book[] = [
  { id: 1, title: '책 1' },
  { id: 2, title: '책 2' },
  { id: 3, title: '책 3' },
];

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

  function selectBook(id: number) {
    if (!document.startViewTransition) {
      setSelectedId(id);
      return;
    }

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

  function goBack() {
    if (!document.startViewTransition) {
      setSelectedId(null);
      return;
    }

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

  if (selectedId === null) {
    return (
      <ul>
        {books.map((book) => (
          <li
            key={book.id}
            style={{ viewTransitionName: `book-${book.id}` }}
          >
            <button type="button" onClick={() => selectBook(book.id)}>
              {book.title}
            </button>
          </li>
        ))}
      </ul>
    );
  }

  const selectedBook = books.find((book) => book.id === selectedId);

  return (
    <section style={{ viewTransitionName: `book-${selectedId}` }}>
      <h1>{selectedBook?.title}</h1>
      <button type="button" onClick={goBack}>
        뒤로가기
      </button>
    </section>
  );
}

flushSync는 캡처 타이밍을 맞추는 데 도움이 되지만, 남용하면 리액트의 동시성 이점을 일부 포기하게 된다. 그래서 전환이 실제로 깨지는 좁은 구간에만 쓰는 편이 좋다. 전환을 위해 애플리케이션 전체 렌더링 전략을 동기화하는 것은 좀 과하다.

리스트 재정렬처럼 같은 문서 안에서 같은 요소가 위치만 바뀌는 경우에는 match-element도 고려할 수 있다.

CSS
.sortable-list > li {
  view-transition-name: match-element;
}

::view-transition-group(*) {
  animation-duration: 240ms;
}

match-element는 브라우저가 같은 문서 안의 요소 대응 관계를 자동으로 잡도록 맡기는 값이다. 다만 문서 간 전환에서는 같은 식별자가 유지된다고 기대하기 어렵기 때문에, MPA나 라우트 경계를 넘는 shared element 전환에서는 명시적인 이름 설계가 더 안전하다.

MPA와 cross-document 전환

View Transition API가 흥미로운 이유 중 하나는 MPA에서도 전환을 다룰 수 있다는 점이다. 같은 출처의 문서 사이를 이동할 때 양쪽 문서가 전환에 참여하도록 선언하면, 브라우저가 문서 간 스냅샷을 이어준다.

기본 형태는 아주 짧다.

CSS
@view-transition {
  navigation: auto;
}

이 선언은 문서가 cross-document view transition에 참여하겠다는 뜻이다. 보통 공통 CSS에 넣어서 출발 문서와 도착 문서가 모두 같은 규칙을 갖도록 만든다. 한쪽에만 규칙이 있으면 전환이 기대와 다르게 보이거나 아예 적용되지 않을 수 있다.

CSS
@view-transition {
  navigation: auto;
}

::view-transition-old(root) {
  animation: fade-out 180ms ease both;
}

::view-transition-new(root) {
  animation: fade-in 220ms ease both;
}

@keyframes fade-out {
  from {
    opacity: 1;
  }

  to {
    opacity: 0;
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }

  to {
    opacity: 1;
  }
}

navigation: auto라고 해서 모든 이동이 전환되는 것은 아니다. 같은 출처인지, 브라우저가 해당 탐색 유형을 전환 대상으로 보는지, 뒤로/앞으로 캐시나 렌더링 상태가 어떤지에 따라 달라질 수 있다. 그래서 MPA 전환은 특히 “되면 더 좋고, 안 되어도 기능은 그대로”라는 태도로 설계하는 편이 맞다.

shared element를 MPA에서 쓰려면 양쪽 문서에 같은 view-transition-name을 지정해야 한다.

HTML
<!-- /posts -->
<a href="/posts/42">
  <img
    src="/covers/42.jpg"
    alt="글 표지"
    style="view-transition-name: post-cover-42"
  />
</a>

<!-- /posts/42 -->
<img
  src="/covers/42.jpg"
  alt="글 표지"
  style="view-transition-name: post-cover-42"
/>

이때도 이름 충돌은 피해야 한다. 목록 화면에 같은 이름이 여러 번 나오거나, 상세 화면에서 같은 이름을 중복으로 쓰면 전환이 안정적으로 구성되지 않는다.

접근성: prefers-reduced-motion

전환은 좋은 사용자에게만 좋은 기능일 수 있다. 모션에 민감한 사용자에게는 짧은 이동도 피로하거나 불편할 수 있다. 그래서 prefers-reduced-motion 대응은 선택 사항이 아니라 기본값에 가깝다.

정책은 둘 중 하나로 정하면 된다. 전환을 거의 끄거나, 아주 짧은 페이드 정도로 줄이거나. 어느 쪽이든 화면마다 제각각이면 안 된다.

CSS
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 1ms !important;
    animation-delay: 0s !important;
  }
}

완전히 비활성화하고 싶다면 전환 시작 자체를 조건으로 막는 방법도 있다.

JS
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

if (reduceMotion || !document.startViewTransition) {
  updateDOM();
} else {
  document.startViewTransition(updateDOM);
}

나는 보통 전역 CSS로 모션을 줄이고, 아주 큰 이동이 있는 전환은 자바스크립트 조건까지 같이 두는 쪽을 선호한다. 전환이 정보 구조를 설명하는 데 도움을 주는 것은 맞지만, 모든 사용자에게 같은 방식으로 강요할 필요는 없다.

도입할 때 조심할 것

View Transition API는 꽤 매력적이지만, 무작정 전역에 켜면 이상한 결과를 만들기도 쉽다. 특히 다음 문제는 자주 만날 가능성이 높다.

운영 기준으로는 이 정도 체크리스트가 현실적이다.

JS
export function safeViewTransition(updateDOM) {
  const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  if (reduceMotion || !document.startViewTransition) {
    updateDOM();
    return;
  }

  const transition = document.startViewTransition(updateDOM);

  transition.ready.catch((error) => {
    console.warn('[view-transition] failed to prepare', error);
  });
}

결국 기준은 화려함이 아니다. 사용자가 화면이 왜 바뀌었는지, 무엇이 어디로 이어졌는지 덜 힘들게 이해할 수 있는가가 중요하다. View Transition API는 그 연결을 브라우저 모델 위에서 만들 수 있게 해준다. 다만 좋은 전환은 여전히 설계의 문제다. 이름을 어떻게 붙일지, 어떤 변화에만 움직임을 줄지, 모션을 줄인 사용자에게 어떻게 보일지까지 정해야 한다.

전환은 장식이 아니라 맥락을 보존하는 장치에 가깝다. 그 관점으로 보면 View Transition API는 꽤 좋은 도구다. 모든 애니메이션 문제를 해결해주는 만능 도구는 아니지만, 적어도 화면 상태의 경계를 브라우저와 함께 다룰 수 있게 해준다는 점만으로도 충분히 의미가 있다.

댓글

댓글을 불러오는 중...