포트폴리오 사이트를 개발하는 과정에서 다양한 애니메이션들을 구현하기 위해 framer-motion 라이브러리를 사용했다.
framer-motion 라이브러리란?
framer-motion 라이브러리는 React 기반의 애니메이션 라이브러리로, JSX에서 바로 애니메이션을 적용할 수 있어 React 컴포넌트와의 조합이 자연스럽고 애니메이션과 state와 쉽게 연동할 수 있어 동적인 UI를 만들기 편하다는 장점이 있다.
framer-motion의 AnimatePresence
컴포넌트를 사용하면 React에서 컴포넌트가 언마운트될 때 exit 애니메이션을 구현할 수 있게 도와준다. 그래서 이를 활용하여 포트폴리오 사이트에서 페이지 전환 애니메이션을 구현하고자 했다.
포트폴리오 사이트는 Next.js 14.2.3
버전과 framer-motion 11.2.6
버전을 사용하고 있다.
// PageTransitionProvider.tsx
'use client';
import { useEffect, useState } from 'react';
import { usePathname } from 'next/navigation';
import { motion, AnimatePresence, Variants } from 'framer-motion';
import { text, translate } from './anim';
const anim = (variants: Variants) => {
return {
variants,
initial: "initial",
animate: "enter",
exit: "exit"
}
}
const curve = (initialPath: string, targetPath: string) => {
return {
initial: {
d: initialPath
},
enter: {
d: targetPath,
transition: {duration: .75, delay: .35, ease: [0.76, 0, 0.24, 1]}
},
exit: {
d: initialPath,
transition: {duration: .75, ease: [0.76, 0, 0.24, 1]}
}
}
}
const SVG = ({
height,
width
}: {
height: number,
width: number,
}) => {
const initialPath = `
M0 300
Q${width / 2} 0 ${width} 300
L${width} ${height + 300}
Q${width / 2} ${height + 600} 0 ${height + 300}
L0 0
`;
const targetPath = `
M0 300
Q${width / 2} 0 ${width} 300
L${width} ${height}
Q${width / 2} ${height} 0 ${height}
L0 0
`;
return (
<motion.svg {...anim(translate)} className='fixed left-0 top-0 pointer-events-none z-[300000]' style={{ width: '100vw', height: 'calc(100vh + 600px)' }}>
<motion.path {...anim(curve(initialPath, targetPath))} fill='#000' />
</motion.svg>
)
}
export function PageTransitionProvider ({
children
}: {
children: React.ReactNode
}) {
const pathname = usePathname();
// 윈도우 사이즈를 저장하기 위한 state
const [dimensions, setDimensions] = useState<{
width: number,
height: number,
}>({
width: -1,
height: -1
});
// 윈도우 사이즈 변화에 유연하게 대응
useEffect( () => {
function resize(){
setDimensions({
width: window.innerWidth,
height: window.innerHeight
});
}
resize();
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
}
}, []);
return (
<AnimatePresence mode="wait" initial={true}>
<motion.div key={pathname}>
<motion.p className='fixed top-[40%] left-1/2 -translate-x-1/2 text-white text-4xl z-[300001] text-center' {...anim(text)}>
{"sdh20282's portfolio"}
</motion.p>
{(dimensions.width !== -1 && dimensions.height !== -1) && <SVG key={pathname} {...dimensions}/>}
{children}
</motion.div>
</AnimatePresence>
);
};
PageTransitionProvider
에서 AnimatePresence
의 자식 컴포넌트인 motion.div
의 key로 pathname을 주어 페이지가 변경될 때마다 exit 애니메이션을 구현하려 했다. 또한 PageTransitionProvider
를 root layout에서 Provider의 형태로 사용하여 전체 페이지 변경에 대한 애니메이션을 적용하려 했다.
위 코드는 문제 없이 동작할 것처럼 보이지만...
이미지와 같이 루트 레이아웃 단에서 적용한 exit 애니메이션이 이전 페이지가 아닌 새로운 페이지에서 작동하는 문제가 발생했다.
원인을 파악하기 위해 두가지 정도를 짚고 넘어갈 필요가 있었다.
AnimatePresence 컴포넌트의 동작 방식
AnimatePresence
컴포넌트는 직계 자식이 언마운트될 때 이를 잠시 보류하고, exit 애니메이션이 끝날 때까지 자식 컴포넌트들을 DOM에 유지한 후, exit 애니메이션이 끝난 뒤 자식 컴포넌트를 갱신하기 위해 재렌더링을 트리거한다. 여기서 AnimatePresence
컴포넌트는 직계 자식이 고유한 key 속성을 가지고 있어야 변경을 감지하고 처리할 수 있다.
실제로 framer-motion 깃허브에서 AnimatePresence
컴포넌트의 구현 내용을 살펴보면 다음과 같다.
// AnimatePresence/index.tsx
export const AnimatePresence = ({
// ...
// 현재 렌더링된 children을 저장
const presentChildren = useMemo(() => onlyElements(children), [children])
// 현재 남아 있는 컴포넌트들의 key를 저장
const presentKeys =
propagate && !isParentPresent ? [] : presentChildren.map(getChildKey)
// ...
// 자식 컴포넌트의 변경을 감지했을 때
if (presentChildren !== diffedChildren) {
// ...
// 모드에 따라 자식 컴포넌트의 언마운트를 보류여부를 결정
// 포트폴리오에서는 애니메이션을 2단계로 구분했기 때문에 mode="wait" 사용
if (mode === "wait" && exitingChildren.length) {
nextChildren = exitingChildren
}
// ...
}
// ...
return (
<>
{renderedChildren.map((child) => {
// ...
const onExit = () => {
// ...
// 모든 자식의 exit 애니메이션이 끝났는지 검사
let isEveryExitComplete = true
exitComplete.forEach((isExitComplete) => {
if (!isExitComplete) isEveryExitComplete = false
})
if (isEveryExitComplete) {
// 강제 재렌더링
forceRender?.()
// ...
}
}
// ...
})}
</>
)
}
다음으로는 app router에서 페이지 라우팅 방식을 확인할 필요가 있었다.
app router에서 페이지 라우팅 방식
app router에서는 OuterLayoutRouter
과 InnerLayoutRouter
컴포넌트를 통해 페이지 라우팅을 관리한다. OuterLayoutRouter
컴포넌트에서는 LayoutRouterContext
컨텍스트에서 현재 페이지 라우팅과 관련된 정보를 가져오고, tree
, cacheNode
, segmentPath
등을 통해 렌더링할 UI를 결정하거나 캐시된 데이터를 관리하는 등 페이지 변경을 위한 준비를 한다. 그리고 InnerLayoutRouter
컴포넌트를 통해 실제로 새로운 UI를 렌더링한다.
여기서 중요한 점은, 페이지 변경이 발생할 때 OuterLayoutRouter
컴포넌트가 하위의 모든 레이아웃과 템플릿을 포함한 내용들을 새로운 경로에 맞게 '완전히' 교체해 버린다는 점이다. 여기서 OuterLayoutRouter
컴포넌트 자체에는 키가 존재하지 않기 때문에 AnimatePresence
컴포넌트로 직접 추적할 수 없고, key 값으로 전달한 pathname 역시 렌더링이 끝난 이후에 업데이트되기 때문에 앞서 보았던 문제가 발생한 상황과 같이 새로운 페이지로 내용이 전환된 이후 exit 애니메이션이 실행된다.
현재 문제가 되는 OuterLayoutRouter
컴포넌트는 LayoutRouterContext
컨텍스트에서 현재 페이지 라우팅과 관련된 정보를 가져온다고 했다.
// layout-router.tsx
// ...
export default function OuterLayoutRouter({
// ...
const context = useContext(LayoutRouterContext)
if (!context) {
throw new Error('invariant expected layout router to be mounted')
}
const { parentTree, parentCacheNode, parentSegmentPath, url } = context
// ...
그렇다면 LayoutRouterContext
를 직접 조작할 수 있다면 이를 해결할 수 있을 것처럼 보인다. 다행히도, 비공식적인 방법이지만 next/dist/shared/lib/app-router-context.shared-runtime
내부 모듈을 통해 LayoutRouterContext
에 접근할 수 있다.
// frozen-router.tsx
import { useContext, useRef } from "react";
import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime";
export function FrozenRouter({ children }: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext)
const frozen = useRef(context).current
return <LayoutRouterContext.Provider value={frozen}>{children}</LayoutRouterContext.Provider>
}
위와 같이 LayoutRouterContext
를 통해 받아온 현재 페이지의 상태를 useRef
에 저장하고, 이전 페이지의 정보를 OuterLayoutRouter
에 전달했다. 이를 통해 페이지 전환이 발생할 경우, exit 애니메이션이 실행되는 동안 이전 페이지를 렌더링하고 exit 애니메이션이 끝난 이후 AnimatePresence
에서 forceRender를 호출하여 강제로 재렌더링을 시켜 다시 FrozenRouter
가 호출되면 useRef
에 저장된 새로운 페이지에 대한 정보를 OuterLayoutRouter
에 전달하도록 처리했다.
다만 해당 방법은 OuterLayoutRouter
컴포넌트를 직접 건드리다보니 app 디렉토리의 loading.js
파일에서 사용되는 Suspense
가 제대로 동작하지 않을 수 있다고 한다. 또한 페이지를 더 많이 렌더링하게 만들어 app-router에 부하를 줄 수 있다고 한다.
앞서 만든 frozen-router를 포트폴리오 사이트에 적용하니 페이지 전환을 동결시키는 데에는 성공할 수 있었으나 컨텐츠가 제대로 렌더링되지 않는 문제가 발생했다.
루트 레이아웃에 적용한, 모든 페이지에서 공통으로 사용되는 정적인 컴포넌트인 Header와 Footer는 정상적으로 렌더링 되었으나, 동적으로 렌더링되는 페이지 컨텐츠는 렌더링되지 않았다.
// app/layout.tsx
import type { Metadata } from "next";
import { Providers } from "@/providers";
import { montserrat, rubik, nanumSquare, alliance } from "@/utils";
import { CommonNavbar, CommonMenu, CommonContact } from "@/layouts";
import "./globals.css";
import { PageTransitionProvider } from "./_providers/components";
export const metadata: Metadata = {
title: {
template: "%s - sdh20282",
default: "sdh20282's Portfolio",
},
description: "sdh20282's portfolio",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${montserrat.variable} ${rubik.variable} ${nanumSquare.variable} ${alliance.variable} relative font-rubik overflow-x-hidden bg-[#232727]`}>
<Providers>
<CommonNavbar />
<CommonMenu />
{children}
<CommonContact />
</Providers>
</body>
</html>
);
}
확인 결과, 현재 AnimatePresence mode="wait"
을 사용 중인데 AnimatePresence mode="popLayout"
을 적용할 경우 정상적으로 렌더링되는 것을 확인할 수 있었다.
따라서 FrozenRouter
와 AnimatePresence
간의 렌더링 타이밍이 일치하지 않아서 해당 문제가 발생하는 것으로 추측했고, FrozenRouter
에서 pathname을 기준으로 렌더링 타이밍을 일치시키기 위한 부분을 추가했다. 또한 보다 안정적으로 이전 내용을 유지하기 위해 usePreviousValue
커스텀 훅을 사용했다.
// use-previous-value.ts
export function usePreviousValue<T>(value: T): T | undefined {
const prevValue = useRef<T>();
useEffect(() => {
prevValue.current = value;
return () => {
prevValue.current = undefined;
};
});
return prevValue.current;
}
// frozen-router.tsx
export function FrozenRouter({ children }: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext);
const prevContext = usePreviousValue(context) || null;
const pathname = usePathname();
const prevPathname = usePreviousValue(pathname);
const changed = pathname !== prevPathname && pathname !== undefined && prevPathname !== undefined;
return (
<LayoutRouterContext.Provider value={changed ? prevContext : context}>
{children}
</LayoutRouterContext.Provider>
);
}
그 결과, 다음과 같이 exit 애니메이션이 의도한대로 동작하는 것을 확인할 수 있었다.
개발 환경에서는 exit 애니메이션이 정상적으로 동작했으나 빌드 후 프로덕션 환경에서 포트폴리오 사이트를 실행했을 때 제대로 동작하지 않는 문제가 발생했다.
페이지 이동 시 개발 환경에서는 FrozenRouter
가 한번만 호출되어 정상적으로 이전 페이지의 내용을 출력할 수 있었으나 프로덕션 환경에서는 FrozenRouter
가 두 번 호출되어 FrozenRouter
가 다음 페이지의 내용을 OuterLayoutRouter
에 전달하는 것이 문제였다.
FrozenRouter
에서 OuterLayoutRouter
에 정보를 갱신하는 타이밍을 명확하게 하기 위해 별도의 상태를 통해 이를 관리하고자 했다.
// frozen-router.tsx
export function FrozenRouter({ children }: { children: React.ReactNode }) {
const context = useContext(LayoutRouterContext);
const prevContext = usePreviousValue(context) || null;
const pathname = usePathname();
const prevPathname = usePreviousValue(pathname);
const [targetContext, setTargetContext] = useState<typeof context>(null);
const [isAnimating, setIsAnimating] = useState(false);
const changed = pathname !== prevPathname && pathname !== undefined && prevPathname !== undefined;
useEffect(() => {
if (pathname !== prevPathname) {
setIsAnimating(true);
setTargetContext(prevContext);
}
}, [pathname, prevPathname]);
useEffect(() => {
if (!isAnimating) return;
const timer = setTimeout(() => {
setIsAnimating(false);
}, 750);
return () => clearTimeout(timer);
}, [isAnimating]);
return (
<LayoutRouterContext.Provider value={changed || isAnimating ? targetContext ?? prevContext : context}>
{children}
</LayoutRouterContext.Provider>
);
}
프로덕션 환경에서도 exit 애니메이션이 의도한대로 동작하는 것을 확인할 수 있었다.
현재 Next.js 15.2 버전에서 View Transitions에 관한 API가 실험적으로 지원된다고 한다. 아직은 이르지만 안정화된다면 이런 번거로운 방법 없이 exit 애니메이션이 동작할 수 있으리라 기대된다.
framer-motion, AnimatePresence/index.tsx, github
App router issue with Framer Motion shared layout animations, Next.js issue
Next13.4 appDir does not render layout and template in the way the docs say it should, Next.js issue
Next.js, layout-router.tsx, github
Exit animation on NextJS 14 Framer Motion, stackoverflow
Next.js 15.2 React View Transitions (experimental), Next.js blog