오늘은 Framer Motion을 활용해서 포트폴리오 첫 화면에 보여줄 인트로 애니메이션을 구현했습니다.
"퍼블리셔에서 → 프론트엔드 개발자로" 문구가 순차적으로 나타났다 사라지는 형태입니다.
퍼블리셔에서, 프론트엔드 개발자로)가 순차적으로 등장"use client";
import { useEffect, useMemo, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
function SplitText({
text,
className,
inDuration = 0.6,
outDuration = 0.4,
stagger = 0.035,
easing = "easeOut",
}: {
text: string;
className?: string;
inDuration?: number;
outDuration?: number;
stagger?: number;
easing?: "linear" | "easeIn" | "easeOut" | "easeInOut" | [number, number, number, number];
}) {
const chars = useMemo(() => Array.from(text), [text]);
return (
<motion.span
className={className}
initial="hidden"
animate="show"
exit="exit"
variants={{
hidden: {},
show: {
transition: { staggerChildren: stagger, ease: easing },
},
exit: {
transition: { staggerChildren: stagger, ease: easing, staggerDirection: -1 },
},
}}
>
{chars.map((c, i) => (
<motion.span
key={i}
className="inline-block will-change-transform"
variants={{
hidden: { y: `0.6em`, opacity: 0 },
show: { y: 0, opacity: 1, transition: { duration: inDuration, ease: easing } },
exit: { y: `-0.6em`, opacity: 0, transition: { duration: outDuration, ease: easing } },
}}
>
{c === " " ? " " : c}
</motion.span>
))}
</motion.span>
);
}
export default function SplashIntro({ children }: { children: React.ReactNode }) {
const [step, setStep] = useState(0);
const [overlayGone, setOverlayGone] = useState(false);
const prefersReduced = useReducedMotion();
const first = "퍼블리셔에서";
const second = "프론트엔드 개발자로";
const cfg = {
inDuration: 0.6,
outDuration: 0.45,
stagger: 0.035,
hold: 0.5,
easing: "easeOut" as const,
};
const firstTotalIn = first.length * cfg.stagger + cfg.inDuration;
const secondTotalIn = second.length * cfg.stagger + cfg.inDuration;
useEffect(() => {
if (prefersReduced) {
setOverlayGone(true);
return;
}
const t1: ReturnType<typeof setTimeout> = setTimeout(() => setStep(1), (firstTotalIn + cfg.hold) * 1000);
const t2: ReturnType<typeof setTimeout> = setTimeout(() => setStep(2), (firstTotalIn + cfg.hold + cfg.outDuration + first.length * cfg.stagger) * 1000);
const t3: ReturnType<typeof setTimeout> = setTimeout(() => setStep(3), (firstTotalIn + cfg.hold + cfg.outDuration + first.length * cfg.stagger + secondTotalIn + cfg.hold) * 1000);
const t4: ReturnType<typeof setTimeout> = setTimeout(() => setStep(4), (firstTotalIn + cfg.hold + cfg.outDuration + first.length * cfg.stagger + secondTotalIn + cfg.hold + cfg.outDuration + second.length * cfg.stagger) * 1000);
return () => {
clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); clearTimeout(t4);
};
}, [prefersReduced]);
useEffect(() => {
if (step === 4) {
const t = setTimeout(() => setOverlayGone(true), 420);
return () => clearTimeout(t);
}
}, [step]);
return (
<div className="relative">
{children}
<AnimatePresence>
{!overlayGone && (
<motion.div
className="fixed inset-0 z-[100] grid place-items-center bg-white dark:bg-neutral-950"
initial={{ opacity: 1 }}
animate={{ opacity: step === 4 ? 0 : 1 }}
transition={{ duration: 0.42, ease: "easeOut" }}
aria-label="Intro splash"
>
<div className="px-6 text-center">
<AnimatePresence mode="popLayout">
{(step === 0 || step === 1) && (
<motion.div key="first" className="leading-tight">
<SplitText
text={first}
className="text-3xl sm:text-5xl font-bold text-neutral-900 dark:text-white break-keep whitespace-nowrap"
inDuration={cfg.inDuration}
outDuration={cfg.outDuration}
stagger={cfg.stagger}
/>
</motion.div>
)}
{(step === 2 || step === 3) && (
<motion.div key="second" className="leading-tight">
<SplitText
text={second}
className="text-3xl sm:text-5xl font-bold text-neutral-900 dark:text-white break-keep whitespace-nowrap"
inDuration={cfg.inDuration}
outDuration={cfg.outDuration}
stagger={cfg.stagger}
/>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
ease에 문자열을 넣을 때 TypeScript가 빨간 밑줄을 표시."linear" | "easeIn" | "easeOut" | "easeInOut" | [number, number, number, number] 추가."퍼블리셔에서"에서 마지막 "서"가 줄바꿈되는 현상 발생.display: inline-block이 적용되면서 부모 컨테이너의 폭 제약에 따라 줄바꿈.white-space: nowrap 적용.useEffect cleanup에서 clearTimeout 호출로 메모리 누수 방지.AnimatePresence와 staggerChildren 조합으로 자연스러운 글자 단위 애니메이션 구현 가능.useReducedMotion을 꼭 고려해야 함.