안녕하세요.
애니메이션은 UX의 완성도를 높이는 도구지만, 관리되지 않은 코드는 비즈니스 로직을 오염시키고 유지보수를 어렵게 만듭니다. 이 문제를 해결하기 위해서 Next.js의
template.tsx를 활용하고 산재해 있던 Motion 코드를 3가지 핵심 패턴으로 표준화했습니다. 코드 복잡도는 낮추고 서비스 코드 일관성을 높인 바디닷의 애니메이션 시스템화 전략과 구체적인 구현 코드를 공유하고자 합니다.
구현 아키텍처: 왜 template.tsx인가?
기존에 애니메이션을 구현하기 위해서 페이지마다
<AnimatePresence>와 <motion.div>를 수동으로 감싸야 했습니다. 하지만 Next.js의 template.tsx를 활용하면 다음과 같은 이점이 있습니다.- 자동화:
Layout과 달리Template은 페이지 전환 시 매번 다시 렌더링되므로, 페이지 진입/이탈 애니메이션 트리거에 최적화되어 있습니다.
- 관심사 분리: 개별 페이지(
page.tsx)는 비즈니스 로직에만 집중하고, 시각적 전환 효과는 템플릿이 전담합니다.
// app/[measurementId]/template.tsx "use client"; import { AnimatePresence } from "framer-motion"; export default function Template({ children }: { children: React.ReactNode }) { return ( <AnimatePresence mode="popLayout"> {children} </AnimatePresence> ); }
바디닷의 공식 애니메이션 패턴 3가지
서비스 전반에 걸쳐 자주 사용되는 3가지 패턴을 정의했습니다.
① Shared Element Transition (Cross-route)
리스트의 아이템을 클릭해 상세 페이지로 이동할 때, 해당 요소가 그대로 확장되며 연결되는 효과입니다. 사용자는 "내가 무엇을 눌렀고, 어디로 이동했는지" 직관적으로 인지하게 됩니다.
- 구현 핵심:
layoutId를 활용해 서로 다른 경로의 컴포넌트를 연결합니다.
- 사용 사례: 상품 상세 진입, 갤러리 썸네일 확장

② Loading Transform
단순한 스피너 대신, 로딩 상태가 성공/실패 아이콘으로 자연스럽게 변형(Morphing)되는 애니메이션입니다. 로딩 시간을 기다림이 아닌 시각적 즐거움을 주며 전환합니다.
- 사용 사례: 운동 처방 생성 중, 데이터 저장 피드백

③ Staggered Animation
목록의 아이템들이 0.1초 간격으로 순차적으로 나타나는 방식입니다. 정보가 한꺼번에 쏟아지는 피로감을 줄이고 정돈된 느낌을 줍니다.
- 사용 사례: 검색 결과 리스트, 메뉴 드롭다운

핵심 코드 (Boilerplate)
① Shared Element Transition (Cross-route)
A페이지에서 B페이지로 전환될 때, ‘main-card’ 라는
layoutId를 가진 컴포넌트가 그대로 이동하여 자연스러운 전환을 연출할 수 있는 애니메이션입니다.- 아래
template.tsx코드처럼 페이지를<AnimatePresence>로 감싸주어야 합니다. 그래야 이전 페이지가 즉시 사라지지 않고 DOM에 남아 있으면서, 다음 페이지의 위치로 자연스럽게 이동할 시간을 벌 수 있습니다.
- 저희는 루트 경로가 아닌, 페이지 단위(하위 경로)에
template.tsx를 배치하여 활용하는 것으로 논의되었습니다.
layoutId로 동일하게 설정해 놓으면, 애니메이션이 자동으로 적용됩니다.
- 화면 내에 동일한 layoutId를 가진 컴포넌트가 중복되어 존재하면 안 됩니다.
// app/[measurementId]/template.tsx "use client"; import { AnimatePresence } from "framer-motion"; export default function Template({ children }: { children: React.ReactNode }) { return ( <AnimatePresence mode="popLayout"> {children} </AnimatePresence> ); }
// app/page.tsx (리스트 페이지) <motion.div layoutId="main-card" className="w-full max-w-[600px] bg-white h-screen sm:h-[calc(100vh-2rem)] flex flex-col shadow-xl sm:rounded-3xl overflow-hidden relative" > <motion.div layout layoutId={`item-${item.id}`} // 리스트 아이템 부분 > ... </motion.div>
// app/generating/page.tsx (생성 페이지) <motion.div layoutId="main-card" className="w-full max-w-[600px] bg-white h-screen sm:h-[calc(100vh-2rem)] flex flex-col shadow-xl sm:rounded-3xl overflow-hidden relative p-8 text-center" > <LoadingIcon /> ... <motion.div layout layoutId={`item-${item.id}`} // 리스트 아이템 부분 > ... </motion.div> ... </motion.div>
Shared Element Transition를 사용할 때 페이지 보일러플레이트와 주의사항 (*필독)
아래 페이지에서
layoutId="main-card"를 가진 GeneratingContent를 다른 파일로 옮기기 위해 분리하면 안 됩니다. 파일을 분리하는 과정에서
Suspense 경계 바깥으로 layoutId를 가진 요소가 나가거나, 렌더링 시점의 불일치가 발생하기 때문에 Framer Motion의 Shared Layout Animation이 정상 동작하지 않습니다."use client"; import { Brain } from "lucide-react"; import { motion } from "framer-motion"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useMemo, useState } from "react"; // layoutId="main-card"를 가진 motion.div가 Suspense 경계 안에 있어야 하므로, // GeneratingContent를 분리하면 안 됩니다. // 분리하면 Framer Motion의 Shared Layout Animation이 정상 동작하지 않습니다. function GeneratingContent() { return ( <motion.div layoutId="main-card" className="w-full max-w-[600px] bg-white h-screen sm:h-[calc(100vh-2rem)] flex flex-col shadow-xl sm:rounded-3xl overflow-hidden relative p-8 text-center" > <div className="relative z-10 flex flex-col items-center w-full mt-32"> <motion.div layoutId="icon-wrapper" className="w-32 h-32 bg-white rounded-full flex items-center justify-center shadow-lg relative mb-10" > <Brain className="w-16 h-16 text-blue-600 animate-pulse" /> <div className="absolute inset-0 -m-3 border-4 border-blue-100 rounded-full animate-[spin_3s_linear_infinite]" /> <div className="absolute inset-0 -m-3 border-t-4 border-blue-500 rounded-full animate-[spin_2s_linear_infinite]" /> </motion.div> <motion.h2 layoutId="status-title" className="text-2xl font-bold text-gray-900 mb-3"> 맞춤 운동 처방 생성 중 </motion.h2> <motion.p layoutId="status-desc" className="text-gray-500 text-lg mb-12"> 고객님의 체형 데이터를 분석하고 있습니다... </motion.p> ... </div> </motion.div> ); } // Next.js App Router에서는 useSearchParams()를 사용하는 클라이언트 컴포넌트를 // Suspense로 감싸야 합니다. // 감싸지 않으면 경고가 발생하거나 동작이 불안정할 수 있습니다. export default function GeneratingPage() { return ( <div className="flex min-h-screen items-center justify-center bg-gray-100 p-0 sm:p-4 font-sans text-black"> <Suspense fallback={null}> <GeneratingContent /> </Suspense> </div> ); }
② Loading Transform
loading Transform 도 마찬가지로 페이지 폴더 하위 경로에
template.tsx 를 만든 후, 각 page에 layoutId 1개를 작성하면 됩니다.// /app/generating/page.tsx // 브레인 아이콘 로딩 부분 <div className="relative z-10 flex flex-col items-center w-full mt-32"> <motion.div layoutId="icon-wrapper" className="w-32 h-32 bg-white rounded-full flex items-center justify-center shadow-lg relative mb-10" > <Brain className="w-16 h-16 text-blue-600 animate-pulse" /> <div className="absolute inset-0 -m-3 border-4 border-blue-100 rounded-full animate-[spin_3s_linear_infinite]" /> <div className="absolute inset-0 -m-3 border-t-4 border-blue-500 rounded-full animate-[spin_2s_linear_infinite]" /> </motion.div> </div>
// /app/complete/page.tsx // 완료 아이콘 부분 <div className="flex flex-col items-center w-full mt-8 mb-8 text-center"> <motion.div layoutId="icon-wrapper" className="w-24 h-24 bg-blue-100 rounded-full flex items-center justify-center shadow-lg mb-6 text-blue-600" > <CheckCircle2 className="w-16 h-16" /> </motion.div> <motion.h2 layoutId="status-title" className="text-2xl font-bold text-gray-900 mb-3"> 처방이 완료되었습니다 </motion.h2> </div>
③ Staggered Animation (List)
Staggered Animation은 variants의 transition만 수정하면 됩니다. 부모의 staggerChildren 값과 자식의 variant 상태를 조정하면 됩니다.- 부모
variants: transition: { staggerChildren: 0.1 }로 자식 간격 제어
- 자식 variants: 부모와 같은 variant 이름(hidden, visible) 사용
1. 간격 조정 (더 빠르게/느리게)
// 부모 variants만 수정 transition: { staggerChildren: 0.05 } // 더 빠르게 transition: { staggerChildren: 0.2 } // 더 느리게
2. 방향 변경 (역순)
// 부모 variants에 추가 transition: { staggerChildren: 0.1, staggerDirection: -1 // 역순으로 애니메이션 }
- 컴포넌트 사용 예시
// app/complete/page.tsx const containerVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.2 }, }, }; const itemVariants: Variants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: 0.5, ease: "easeOut" } }, }; return ( <motion.div variants={containerVariants} initial="hidden" animate="visible" className="flex-1 overflow-y-auto space-y-4 mb-8" > <p className="text-sm font-semibold text-gray-400 uppercase tracking-wider text-left">선택된 운동 루틴</p> <div className="grid gap-3"> {selectedExercises.map((ex) => ( <motion.div key={ex.key} variants={itemVariants} className="flex p-4 bg-white rounded-[32px] border border-gray-100 shadow-sm items-stretch text-left" > ... </motion.div> ))} </div> </motion.div> );
Shared Element Transition, Loading Transform 애니메이션 제어하기
서비스 기능에 따라서 애니메이션 커스텀을 해야하는 경우에는
transition prop을 사용하면 됩니다.transition는 A, B 페이지 중에서 B페이지(목적지)에서 정의합니다.<motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9, transition: { duration: 0.2, ease: "easeIn" }, }} transition={{ type: "spring", stiffness: 200, damping: 15, mass: 0.8, }} className="w-full max-w-[550px] bg-white rounded-3xl shadow-2xl overflow-hidden relative z-10 max-h-[90vh] flex flex-col" > .... </motion.div>
참고 문서
기술적 주의사항
- Cross-route 구현 시
layoutId활용 layoutId매칭은 1:1 관계이어야 합니다. → 화면(페이지)당 1개 존재해야 합니다.- 단순히 페이지 전환(Fade in/out 등)만 처리할 때는
layoutId를 명시하지 않아도 됩니다. -
layoutId="main-card"를 가진 GeneratingContent를 다른 파일로 옮기기 위해 분리하면 안 됩니다. 파일을 분리하는 과정에서Suspense경계 바깥으로layoutId를 가진 요소가 나가거나, 렌더링 시점의 불일치가 발생하기 때문에 Framer Motion의 Shared Layout Animation이 정상 동작하지 않습니다.
<AnimatePresence mode="popLayout">의popLayout모드와 CSS 레이아웃popLayout은 이전 페이지가 사라지는(exit) 동안 그 자리를 유지하게 해줍니다.- 이때 페이지 전체를 감싸는 컨테이너에 최소 높이(
min-h-screen)가 지정되어 있어야 전환 중 화면이 갑자기 위로 솟구치는 현상을 방지합니다.
테스트 코드
아래 레포지토리에서 코드와 애니메이션을 직접 확인하실 수 있습니다.
표준화된 애니메이션 패턴은 개발 효율성과 디자인 일관성을 동시에 잡을 수 있는 최선의 선택이라고 생각합니다. 공유드린 3가지 패턴과 주의사항을 숙지하여, 완성도 높은 애니메이션을 구현해 보시길 바랍니다!
참고 문서
.png)