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


여느 때처럼 잘 만들어진 사이트를 구경하던 중
무언가 화면 전환이 부드러운 느낌이 드는 사이트들이 있었다.
그래서 찾아봤더니 View Transitions API
라는 것을 발견했다.
이전에 써봤던 화면 전환 애니메이션들은
- Ionic의 기본 내장 라우터 애니메이션
- Framer의 AnimatePresence
위 두 라이브러리의 DOM 트리 2개 교차 방식
이었다.
1. View Transitions API란
쉽게 말하자면 이전 DOM element를 캡처하고,
다음 DOM element로 자연스러운 애니메이션을 주는 API이다.
이를 사용하기 위한 startViewTransition
는 document
기본 내장 API로
현재 Firefox
, Firefox for Android
빼고는 모든 브라우저를 지원한다. (25.01월 기준)
그런데 Next
에서는 아직 적극 지원을 해주지 않고 있다.
하지만 나는 Next
로 블로그를 만들었기 때문에.. 어떻게든 넣고 싶었다.
2. 화면 전환을 부드럽게 해보자
SPA에서는
javascriptfunction updateView(callback) { if ('startViewTransition' in document) { document.startViewTransition(callback); } else { callback(); }}
그저 이렇게만 사용하면 만사형통이지만
Next
에서는 페이지 전환시 애니메이션이 동작하지 않았다;;
이유는 다음과 같다.
이전 DOM Element
를 캡쳐하고 다음 DOM Element
로 연결시켜야 하는데
Next
는 페이지 전환시 DOM을 통째로 갈아엎는다.
그래서 이전 DOM Element
가 없어진다!
다른 사람들도 불만이 많았는지
Next 깃허브의 Discussions에 많은 불만이 올라와 있었다.
Vercel
측 담당자도 지원할 계획이라고 한다.
그치만 나는 당장 화면 전환 애니메이션을 주고 싶었다.
Discussions의 코드를 참고해보며 참 많은 삽질을 했다.
우선 첫 번째 계획이었다.
목록에서 상세 페이지로 전환할 때 썸네일이 자연스럽게 확장되도록 구현하려 했다.
Astro
로 구현된 페이지의 js
코드를 가져와 분석 후 적용해보았다.
대충 코드 내용은 다음과 같다.
javascriptfunction 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. 이미지 로드 문제 처리하기
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;
이렇게 링크를 클릭하면 이미지를 미리 불러와 브라우저 캐시에 저장한다.
결과는?
목록을 클릭하자마자 상세 페이지 이미지를 가져와 바로 로드한다.
딱 한 가지 단점이라고 한다면,
상세 페이지 이미지가 로드되기 전까지 페이지 이동이 지연된다는 것 정도이다.
그런데 문득 이렇게까지 해야 하나?
라는 생각이 들었다.
4. 이미지 최적화
어차피 같은 이미지를 불러오기도 하고, 썸네일은 단순한 이미지라
굳이 srcset
으로 최적화를 할 이유가 없었다.
그래서 차라리 단일 이미지 주소를 사용하되, 이미지 원본 자체를 최적화하기로 했다.
javascriptconst 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" },// ...
평균적으로 이미지 원본 크기를 120 ~ 180
배 감소시켰다.
또 카드 목록과 리스트 형태 목록도 transition을 적용했다.
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;
이렇게 트릭을 주었다.
그런데 View Transition API를 이용하면 트릭을 주지 않고
애니메이션을 부드럽게 줄 수 있을 것 같았다.
tsxconst 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
만 추가해주었다.
끝장난다.