뒤로가기
View Transitions API로 페이지 전환을 네이티브 앱처럼 만들기

February 10, 2025

frontendcssnextjs

페이지를 이동할 때마다 화면이 번쩍거렸다. 흰 화면이 잠깐 보이고, 콘텐츠가 와르르 밀려 들어오는 그 느낌. SPA인데도 네이티브 앱과 비교하면 어딘가 어색했다. Framer Motion으로 페이지 전환 애니메이션을 붙여볼까 했는데, 번들 사이즈가 신경 쓰였고, AnimatePresence로 exit 애니메이션을 제대로 구현하려면 레이아웃 구조를 꽤 많이 바꿔야 했다.

그러다 View Transitions API를 발견했다.

View Transitions API가 뭔가#

브라우저가 DOM 변경 전후의 스냅샷을 찍어서, 두 상태 사이를 자동으로 애니메이션 해주는 API다. 핵심은 document.startViewTransition() 하나.

javascript
document.startViewTransition(() => {
  // DOM을 변경하는 코드
  updateContent();
});

이 함수를 호출하면 브라우저가 이런 일을 한다:

  1. 현재 화면의 스크린샷을 찍는다 (old state)
  2. 콜백 안의 DOM 변경을 실행한다
  3. 새 화면의 스크린샷을 찍는다 (new state)
  4. 두 스크린샷 사이를 CSS 애니메이션으로 전환한다

별도 라이브러리 없이 브라우저가 알아서 처리한다. 기본 전환은 크로스페이드인데, CSS로 커스터마이징할 수 있다.

Next.js App Router에서 적용하기#

문제는 Next.js의 라우팅이 View Transitions API와 바로 연동되지 않는다는 점이었다. router.push()가 내부적으로 DOM을 어떻게 업데이트하는지 외부에서 제어할 수 없으니까.

결국 useRouter를 감싸는 훅을 하나 만들었다.

typescript
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';

export function useViewTransitionRouter() {
  const router = useRouter();

  const push = useCallback(
    (href: string) => {
      if (!document.startViewTransition) {
        router.push(href);
        return;
      }

      document.startViewTransition(() => {
        router.push(href);
      });
    },
    [router]
  );

  return { ...router, push };
}

startViewTransition을 지원하지 않는 브라우저에서는 그냥 일반 네비게이션으로 동작한다. progressive enhancement라서 부담이 없다.

CSS로 전환 애니메이션 커스터마이징#

기본 크로스페이드도 나쁘지 않은데, 좀 더 앱다운 느낌을 주고 싶었다. 슬라이드 전환을 적용했다.

css
@keyframes slide-from-right {
  from {
    transform: translateX(100%);
  }
}

@keyframes slide-to-left {
  to {
    transform: translateX(-100%);
  }
}

::view-transition-old(root) {
  animation: 300ms ease-out slide-to-left;
}

::view-transition-new(root) {
  animation: 300ms ease-out slide-from-right;
}

::view-transition-old::view-transition-new라는 의사 요소가 핵심이다. 브라우저가 만들어주는 스크린샷 레이어에 직접 애니메이션을 걸 수 있다.

view-transition-name으로 요소 연결하기#

진짜 재미있는 건 여기서부터다. 특정 요소에 view-transition-name을 지정하면, 페이지가 바뀌어도 같은 이름의 요소끼리 연결돼서 자연스럽게 이동한다.

블로그 목록에서 포스트 상세로 넘어갈 때, 썸네일이 제자리에서 확대되면서 상세 페이지의 히어로 이미지로 변하는 효과를 만들었다.

css
/* 목록 페이지의 썸네일 */
.post-thumbnail {
  view-transition-name: post-image;
}

/* 상세 페이지의 히어로 이미지 */
.post-hero {
  view-transition-name: post-image;
}

이게 전부다. 같은 view-transition-name을 가진 요소끼리 브라우저가 알아서 위치, 크기, 형태를 보간해서 애니메이션을 만든다.

Warning

view-transition-name은 페이지 내에서 유일해야 한다. 목록 페이지에서 여러 카드에 같은 이름을 주면 동작하지 않는다. 클릭한 카드에만 동적으로 이름을 부여해야 한다.

삽질: 동적으로 view-transition-name 부여하기#

목록 페이지에서 카드를 클릭할 때, 해당 카드에만 view-transition-name을 넣어야 한다. 처음에는 state로 관리하려고 했는데, 타이밍 이슈가 생겼다. state 업데이트가 반영되기 전에 startViewTransition이 스냅샷을 찍어버리는 거다.

ref로 DOM을 직접 조작하는 방식으로 해결했다.

typescript
function PostCard({ post }: { post: Post }) {
  const router = useViewTransitionRouter();
  const imageRef = useRef<HTMLImageElement>(null);

  const handleClick = () => {
    if (imageRef.current) {
      imageRef.current.style.viewTransitionName = 'post-image';
    }
    router.push(`/blog/${post.slug}`);
  };

  return (
    <article onClick={handleClick}>
      <img ref={imageRef} src={post.thumbnail} alt={post.title} />
      <h2>{post.title}</h2>
    </article>
  );
}

깔끔한 방법은 아닌데, 동작은 확실하다. startViewTransition 콜백 안에서 state 업데이트를 하면 React의 배칭과 충돌할 수 있어서, 이런 경우에는 ref가 안전하다.

결과#

라이브러리 추가 없이, CSS와 훅 하나로 페이지 전환이 훨씬 자연스러워졌다. 번들 사이즈 증가는 0. Lighthouse 점수에 영향도 없다.

2026년 3월 기준으로 Chrome, Edge, Opera에서 지원하고, Safari는 17.2부터 부분 지원한다. Firefox는 아직이지만, startViewTransition이 없으면 그냥 일반 네비게이션으로 동작하니까 문제될 건 없다.

Framer Motion이 나쁜 건 아니다. 컴포넌트 단위의 복잡한 애니메이션에는 여전히 최고다. 그런데 페이지 전환만 놓고 보면, View Transitions API가 훨씬 가볍고 선언적이다. 브라우저가 해줄 수 있는 일은 브라우저에게 맡기자.