Framer Motion으로 만드는 포트폴리오 인트로 페이지

Alchemist·2025년 8월 12일

오늘은 Framer Motion을 활용해서 포트폴리오 첫 화면에 보여줄 인트로 애니메이션을 구현했습니다.
"퍼블리셔에서 → 프론트엔드 개발자로" 문구가 순차적으로 나타났다 사라지는 형태입니다.


1. 구현 목표

  • 문구가 한 글자씩 부드럽게 나타났다 사라지는 애니메이션
  • 두 개의 문구(퍼블리셔에서, 프론트엔드 개발자로)가 순차적으로 등장
  • 인트로가 끝나면 메인 페이지로 자연스럽게 전환
  • 다크 모드 지원 및 모션 저감 환경 고려

2. 핵심 구현 코드

SplashIntro 컴포넌트

  • 한 글자씩 분리해 stagger(순차 애니메이션)를 적용하는 유틸 컴포넌트입니다.
  • 문구 전환 타이밍을 계산하고, 상태(step)를 변경하면서 애니메이션을 순차적으로 실행합니다.
"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>
  );
}

3. 작업하면서 겪은 문제들

  1. ease 타입 에러
  • Framer Motion에서 ease에 문자열을 넣을 때 TypeScript가 빨간 밑줄을 표시.
  • 해결: 타입에 "linear" | "easeIn" | "easeOut" | "easeInOut" | [number, number, number, number] 추가.
  1. 마지막 글자 줄바꿈 문제
  • "퍼블리셔에서"에서 마지막 "서"가 줄바꿈되는 현상 발생.
  • 원인: 문자 단위 애니메이션에서 display: inline-block이 적용되면서 부모 컨테이너의 폭 제약에 따라 줄바꿈.
  • 해결: 부모 요소에 white-space: nowrap 적용.
  1. 타이머 정리
  • 페이지 전환 중에도 타이머가 실행되어 의도치 않은 상태 변경 발생.
  • 해결: useEffect cleanup에서 clearTimeout 호출로 메모리 누수 방지.

4. 배운 점

  • Framer Motion의 AnimatePresencestaggerChildren 조합으로 자연스러운 글자 단위 애니메이션 구현 가능.
  • 접근성을 위해 useReducedMotion을 꼭 고려해야 함.
  • UI 애니메이션은 타이밍 계산이 핵심 → 문자 수 기반 타이밍 산출이 효과적.
profile
html_programming_language

0개의 댓글