logo

Blog

블로그 화면 전환 부드럽게 하기 (feat. view transition api)

#nextjs#view transition#blog
ryxxn profileryxxn
2025.01.10
thumbnail

여느 때처럼 잘 만들어진 사이트를 구경하던 중
무언가 화면 전환이 부드러운 느낌이 드는 사이트들이 있었다.

그래서 찾아봤더니 View Transitions API라는 것을 발견했다.

이전에 써봤던 화면 전환 애니메이션들은

  • Ionic의 기본 내장 라우터 애니메이션
  • Framer의 AnimatePresence

위 두 라이브러리의 DOM 트리 2개 교차 방식이었다.


1. View Transitions API란

쉽게 말하자면 이전 DOM element를 캡처하고,
다음 DOM element로 자연스러운 애니메이션을 주는 API이다.

이를 사용하기 위한 startViewTransitiondocument 기본 내장 API로
현재 Firefox, Firefox for Android 빼고는 모든 브라우저를 지원한다. (25.01월 기준)

그런데 Next에서는 아직 적극 지원을 해주지 않고 있다.

하지만 나는 Next로 블로그를 만들었기 때문에.. 어떻게든 넣고 싶었다.


2. 화면 전환을 부드럽게 해보자

SPA에서는

javascript
function updateView(callback) {  if ('startViewTransition' in document) {    document.startViewTransition(callback);  } else {    callback();  }}

그저 이렇게만 사용하면 만사형통이지만
Next에서는 페이지 전환시 애니메이션이 동작하지 않았다;;

이유는 다음과 같다.

이전 DOM Element를 캡쳐하고 다음 DOM Element로 연결시켜야 하는데
Next는 페이지 전환시 DOM을 통째로 갈아엎는다.

그래서 이전 DOM Element가 없어진다!


다른 사람들도 불만이 많았는지
Next 깃허브의 Discussions에 많은 불만이 올라와 있었다.

https://github.com/vercel/next.js/discussions/46300

Vercel측 담당자도 지원할 계획이라고 한다.

vercel-support-view-transitions-api

그치만 나는 당장 화면 전환 애니메이션을 주고 싶었다.

Discussions의 코드를 참고해보며 참 많은 삽질을 했다.


우선 첫 번째 계획이었다.

first-plan

목록에서 상세 페이지로 전환할 때 썸네일이 자연스럽게 확장되도록 구현하려 했다.

Astro로 구현된 페이지의 js 코드를 가져와 분석 후 적용해보았다.

대충 코드 내용은 다음과 같다.

javascript
function DOM_교체하기(html) {  document.getElementById('content').innerHTML = html;}function 보존된_요소를_넣을_컨테이너_찾기(context = document) {  return context.querySelector('[data-persist-container="true"]');}function 보존할_요소_찾기(context = document) {  return context.querySelector('[data-persist="true"]');}
/** * navigate event 등록 * */navigation.addEventListener('navigate', (event) => {  const 다음페이지 = new URL(event.destination.url);
  페이지_전환하기(event, 다음페이지);});
/** * 다음 페이지를 미리 불러와 html을 교체한다. * 이전 DOM Element를 보존하여 다음 DOM Element와 연결시킴 * */function 페이지_전환하기(event, 다음페이지주소) {  event.intercept({    scroll: 'manual',    async handler() {      const 미리_불러온_다음_페이지 = await fetch(다음페이지주소);
      const 보존된_요소 = 보존할_요소_찾기();
      document.startViewTransition(() => {        DOM_교체하기(미리_불러온_다음_페이지);
        // 보존된 Element로 교체        const 보존된_요소를_넣을_컨테이너 = 보존된_요소를_넣을_컨테이너_찾기();        보존된_요소를_넣을_컨테이너.replaceChildren(보존된_요소);      });    },  });}

분석해보니 이런 느낌이어서 코드를 적용하고 실행해보았다.


웹페이지가 뭉개졌다.

navigate API를 건드렸더니, SPA도 동작을 하지 않고, 캡처 시점도 원하는대로 동작하지 않았다.

이런식으로 계속 삽질을 하다가

https://github.com/shuding/next-view-transitions

라이브러리를 발견했다.


일단 View Transition API는 적용이 됐다.

그러나, 문제가 생겼다.


3. 이미지 로드 문제 처리하기

transition-blur-problem

Next Image 컴포넌트를 사용해서 이미지를 최적화하고 있었기 때문에,

ex)

  • 목록 썸네일 = /_next/image?url=이미지주소&w=256&q=75
  • 상세 페이지 썸네일 = /_next/image?url=이미지주소&w=672&q=75

상세 페이지에 있는 큰 사이즈의 이미지는 다른 이미지로 인식하여 새로 가져오는 것이었다.

그래서 이미지를 pre load할 방법을 생각했다.

typescript
'use client';
import Link, { LinkProps } from 'next/link';import { useRouter } = 'next/navigation';
interface Props extends LinkProps {  preloadImageUrl: string;  children: React.ReactNode;  className?: string;}
const preloadImage = (url: string) => {  const img = new Image();  img.src = url; // 브라우저 캐시에 저장};
const PreloadImageLink = ({ preloadImageUrl, href, ...props }: Props) => {  const { push } = useRouter();
  const onClick = (e) => {    e.preventDefault();    preloadImage(preloadImageUrl);    push(href)  };
  return (    <Link onClick={onClick} {...props}>      {props.children}    </Link>  );};
export default PreloadImageLink;

이렇게 링크를 클릭하면 이미지를 미리 불러와 브라우저 캐시에 저장한다.

결과는?

image-preload-result

목록을 클릭하자마자 상세 페이지 이미지를 가져와 바로 로드한다.

딱 한 가지 단점이라고 한다면,
상세 페이지 이미지가 로드되기 전까지 페이지 이동이 지연된다는 것 정도이다.

그런데 문득 이렇게까지 해야 하나?라는 생각이 들었다.


4. 이미지 최적화

어차피 같은 이미지를 불러오기도 하고, 썸네일은 단순한 이미지라
굳이 srcset으로 최적화를 할 이유가 없었다.

그래서 차라리 단일 이미지 주소를 사용하되, 이미지 원본 자체를 최적화하기로 했다.

javascript
const sharp = require('sharp');const glob = require('glob');const fs = require('fs');
async function optimizeImages() {  const images = glob.sync('public/assets/articles/**/*.{jpg,jpeg,png}');
  for (const image of images) {    const outputPath = image.replace(/\.(jpg|jpeg|png)$/, '.webp');
    await sharp(image)      .webp({ quality: 90 })      .resize(672, null, {        withoutEnlargement: true,        fit: 'inside',      })      .toFile(outputPath);
    // delete original image after optimization    fs.unlinkSync(image);  }}
optimizeImages();

어차피 이미지의 최대 사이즈는 672px였기에,
이미지 resize + 최적화 및 webp 확장자로 변환했다.

그리고, 빌드시 이미지 최적화 변환 작업을 추가했다.

bash
// packages.json// ...  "scripts": {    "dev": "next dev --turbopack",    "start": "next start",    "lint": "next lint",    "build": "npm run optimize-images && next build",    "optimize-images": "node scripts/optimize-images.js"  },// ...

optimized-image-file-size

평균적으로 이미지 원본 크기를 120 ~ 180배 감소시켰다.


또 카드 목록과 리스트 형태 목록도 transition을 적용했다.

add-transition-result


5. (추가) 탭바 transition 적용

현재 블로그 상단에 카테고리 탭바가 있다.

아무래도 카테고리별 URL이 다르니 탭 하단바 이동 애니메이션이 원활하지 않았다.

그래서 기존에는

tsx
// ...
const Tab = ({ tabs, className = '', ...other }: TabProps) => {  const pathname = usePathname();  const activeTabIndex = tabs.findIndex((tab) =>    isEqualPath(tab.href, pathname)  );
  return (    <div className={`... ${className}`} {...other}>      <div className="...">        {tabs.map((tab, index) => (          <Link key={index} href={tab.href} className="text-center">            <div className="...">              <span                className={`                ${                  activeTabIndex === index                    ? 'text-black dark:text-white font-semibold'                    : 'text-gray-600 dark:text-gray-300'                }              `}              >                {tab.label}              </span>              // 탭바 움직이도록 애니메이션              {activeTabIndex === index && (                <motion.div                  layoutId="underline"                  className="absolute left-0 right-0 bottom-0 h-0.5 bg-black dark:bg-white"                />              )}            </div>          </Link>        ))}      </div>    </div>  );};
export default Tab;

이렇게 트릭을 주었다.

tab-bar-animation-before


그런데 View Transition API를 이용하면 트릭을 주지 않고
애니메이션을 부드럽게 줄 수 있을 것 같았다.

tsx
const Tab = ({ tabs, className = '', ...other }: TabProps) => {  // ...
  return (    <div className={`... ${className}`} {...other}>      <div className="...">        {tabs.map((tab, index) => (          <Link key={index} href={tab.href} className="text-center">            // ...
              {activeTabIndex === index && (                <div                  className="absolute left-0 right-0 bottom-0 h-0.5 bg-black dark:bg-white"                  // view-transition-name 추가                  style={{                    viewTransitionName: 'category-tab-underline',                  }}                />              )}            </div>          </Link>        ))}      </div>    </div>  );};
export default Tab;

이렇게 기존 코드에서 애니메이션 코드를 전부 제거하고, view-transition-name만 추가해주었다.

tab-bar-animation-after

끝장난다.

Related Articles