정적인 포트폴리오 페이지에 조금 인터랙티브한 애니메이션을 추가해 보았다. 특히 스크롤 기반 네비게이션과 3D 프로젝트 캐러셀 구현에 중점을 두었다.

왜 애니메이션인가?

포트폴리오는 개발자의 첫인상을 결정하는 중요한 요소다. 단순히 정보를 나열하는 것보다 사용자와 상호작용하는 동적인 경험을 제공하고 싶었다. 잘 만들어진 애니메이션은:

  • 자연스러운 전환으로 사용자의 인지적 부담을 줄인다
  • 중요한 콘텐츠에 시선을 집중시킨다
  • 전문성과 디테일에 대한 관심을 보여준다
📝 Note

- 사용자 행동에 즉각적인 피드백을 제공한다

스크롤 기반 섹션 네비게이션

1. 스크롤 인터셉션 구현

가장 먼저 구현한 것은 히어로 섹션에서 스크롤할 때 자동으로 프로젝트 섹션으로 이동하는 기능이다.

import { useEffect, useRef, useState } from "react";
 
export function useScrollNavigation() {
  const [isScrolling, setIsScrolling] = useState(false);
  const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
  const heroRef = useRef<HTMLElement>(null);
  const projectsRef = useRef<HTMLElement>(null);
 
  // 현재 히어로 섹션에 있는지 확인
  const isInHeroSection = () => {
    if (!heroRef.current) return false;
    const rect = heroRef.current.getBoundingClientRect();
    return rect.top <= 100 && rect.bottom > 100;
  };
 
  // 프로젝트 섹션으로 스크롤
  const scrollToProjects = () => {
    if (projectsRef.current && !isScrolling) {
      setIsScrolling(true);
      projectsRef.current.scrollIntoView({
        behavior: "smooth",
        block: "start",
      });
 
      setTimeout(() => {
        setIsScrolling(false);
      }, 1000);
    }
  };
 
  // 히어로 섹션으로 스크롤
  const scrollToHero = () => {
    if (heroRef.current && !isScrolling) {
      setIsScrolling(true);
      heroRef.current.scrollIntoView({
        behavior: "smooth",
        block: "start",
      });
 
      setTimeout(() => {
        setIsScrolling(false);
      }, 1000);
    }
  };
 
  // 휠 이벤트 핸들러
  useEffect(() => {
    const handleWheel = (e: WheelEvent) => {
      if (isScrolling) return;
 
      const inHero = isInHeroSection();
 
      // 히어로 섹션에서 아래로 스크롤
      if (inHero && e.deltaY > 0) {
        e.preventDefault();
 
        // 디바운싱 처리
        if (scrollTimeoutRef.current) {
          clearTimeout(scrollTimeoutRef.current);
        }
 
        scrollTimeoutRef.current = setTimeout(() => {
          scrollToProjects();
        }, 50);
      }
      // 프로젝트 섹션 상단에서 위로 스크롤
      else if (!inHero && e.deltaY < 0) {
        const projectsElement = projectsRef.current;
        if (projectsElement) {
          const projectsRect = projectsElement.getBoundingClientRect();
          const isNearProjectsTop = 
            projectsRect.top >= -200 && projectsRect.top <= 200;
 
          if (isNearProjectsTop) {
            e.preventDefault();
            scrollToHero();
          }
        }
      }
    };
 
    window.addEventListener("wheel", handleWheel, { passive: false });
 
    return () => {
      window.removeEventListener("wheel", handleWheel);
      if (scrollTimeoutRef.current) {
        clearTimeout(scrollTimeoutRef.current);
      }
    };
  }, [isScrolling]);
 
  // 터치 이벤트 처리
  useEffect(() => {
    let startY = 0;
    let endY = 0;
 
    const handleTouchStart = (e: TouchEvent) => {
      startY = e.touches[0].clientY;
    };
 
    const handleTouchEnd = (e: TouchEvent) => {
      if (isScrolling) return;
 
      endY = e.changedTouches[0].clientY;
      const deltaY = startY - endY;
      const inHero = isInHeroSection();
 
      // 최소 스와이프 거리 50px
      if (Math.abs(deltaY) > 50) {
        if (inHero && deltaY > 0) {
          scrollToProjects();
        } else if (!inHero && deltaY < 0) {
          const projectsElement = projectsRef.current;
          if (projectsElement) {
            const projectsRect = projectsElement.getBoundingClientRect();
            const isNearProjectsTop = projectsRect.top >= -200 && projectsRect.top <= 200;
 
            if (isNearProjectsTop) {
              scrollToHero();
            }
          }
        }
      }
    };
 
    window.addEventListener("touchstart", handleTouchStart, { passive: true });
    window.addEventListener("touchend", handleTouchEnd, { passive: true });
 
    return () => {
      window.removeEventListener("touchstart", handleTouchStart);
      window.removeEventListener("touchend", handleTouchEnd);
    };
  }, [isScrolling]);
 
  return {
    heroRef,
    projectsRef,
    isScrolling,
    scrollToProjects,
    scrollToHero,
  };
}

2. 스크롤 상태 관리

스크롤 중복 실행을 방지하고 부드러운 전환을 위해 상태 관리가 중요했다. isScrolling 플래그로 애니메이션 중 추가 스크롤을 차단하고, 디바운싱으로 과도한 이벤트 호출을 방지했다.

3D 프로젝트 캐러셀 구현

1. 반응형 캐러셀 컴포넌트

데스크톱에서는 3개의 카드를 동시에 보여주고, 모바일에서는 단일 카드로 전환되는 반응형 캐러셀을 구현했다.

import React, { useState, useEffect } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
 
interface Project {
  id: number;
  title: string;
  description: string;
  image: string;
  technologies: string[];
  githubUrl?: string;
  liveUrl?: string;
}
 
interface ProjectCarouselProps {
  projects: Project[];
  onImageClick: (image: string) => void;
}
 
export default function ProjectCarousel({ projects, onImageClick }: ProjectCarouselProps) {
  const [currentProjectIndex, setCurrentProjectIndex] = useState(0);
 
  // 프로젝트 네비게이션
  const nextProject = () => {
    setCurrentProjectIndex((prev) => (prev + 1) % projects.length);
  };
 
  const prevProject = () => {
    setCurrentProjectIndex((prev) => 
      prev === 0 ? projects.length - 1 : prev - 1
    );
  };
 
  // 키보드 네비게이션
  useEffect(() => {
    const handleKeyPress = (e: KeyboardEvent) => {
      if (e.key === "ArrowLeft") {
        prevProject();
      } else if (e.key === "ArrowRight") {
        nextProject();
      }
    };
 
    window.addEventListener("keydown", handleKeyPress);
    return () => window.removeEventListener("keydown", handleKeyPress);
  }, []);
 
  // 인디케이터 컴포넌트
  const Indicators = () => (
    <div className="flex justify-center mt-8 space-x-2 mb-1">
      {projects.map((_, index) => (
        <button
          key={index}
          onClick={() => setCurrentProjectIndex(index)}
          className={`w-2.5 h-2.5 rounded-full transition-all duration-300 ${
            index === currentProjectIndex
              ? "bg-blue-500 scale-125 shadow-md"
              : "bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 hover:scale-110"
          }`}
          aria-label={`Go to project ${index + 1}`}
        />
      ))}
    </div>
  );
 
  return (
    <div className="w-full">
      {/* 데스크톱용 3카드 캐러셀 */}
      <div className="hidden lg:flex items-center justify-center gap-4 lg:gap-6">
        {/* 이전 프로젝트 카드 (왼쪽) */}
        <div
          className="w-72 lg:w-80 flex-shrink-0 transform scale-75 lg:scale-80 
                     opacity-60 hover:opacity-80 transition-all duration-500 
                     cursor-pointer"
          onClick={prevProject}
          role="button"
          aria-label="Previous project"
        >
          <ProjectCard
            project={projects[(currentProjectIndex - 1 + projects.length) % projects.length]}
            isActive={false}
            onImageClick={() => {}}
          />
        </div>
 
        {/* 현재 프로젝트 카드 (중앙) */}
        <div className="w-72 lg:w-[22rem] flex-shrink-0 transform scale-100 
                       z-10 shadow-xl">
          <ProjectCard
            project={projects[currentProjectIndex]}
            isActive={true}
            onImageClick={onImageClick}
          />
        </div>
 
        {/* 다음 프로젝트 카드 (오른쪽) */}
        <div
          className="w-72 lg:w-80 flex-shrink-0 transform scale-75 lg:scale-80 
                     opacity-60 hover:opacity-80 transition-all duration-500 
                     cursor-pointer"
          onClick={nextProject}
          role="button"
          aria-label="Next project"
        >
          <ProjectCard
            project={projects[(currentProjectIndex + 1) % projects.length]}
            isActive={false}
            onImageClick={() => {}}
          />
        </div>
      </div>
 
      {/* 모바일/태블릿용 단일 카드 + 버튼 네비게이션 */}
      <div className="lg:hidden relative flex justify-center">
        <div className="w-full max-w-md mx-auto px-16">
          <ProjectCard
            project={projects[currentProjectIndex]}
            isActive={true}
            onImageClick={onImageClick}
          />
        </div>
 
        {/* 좌측 네비게이션 버튼 */}
        <button
          className="absolute left-1 top-1/2 -translate-y-1/2 h-10 w-10 
                     rounded-full bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm 
                     shadow-lg hover:scale-110 transition-transform duration-200
                     flex items-center justify-center"
          onClick={prevProject}
          aria-label="Previous project"
        >
          <ChevronLeft className="h-5 w-5" />
        </button>
 
        {/* 우측 네비게이션 버튼 */}
        <button
          className="absolute right-1 top-1/2 -translate-y-1/2 h-10 w-10 
                     rounded-full bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm 
                     shadow-lg hover:scale-110 transition-transform duration-200
                     flex items-center justify-center"
          onClick={nextProject}
          aria-label="Next project"
        >
          <ChevronRight className="h-5 w-5" />
        </button>
      </div>
 
      {/* 인디케이터 */}
      <Indicators />
    </div>
  );
}
 
// 프로젝트 카드 컴포넌트
interface ProjectCardProps {
  project: Project;
  isActive: boolean;
  onImageClick: (image: string) => void;
}
 
function ProjectCard({ project, isActive, onImageClick }: ProjectCardProps) {
  return (
    <div 
      className={`bg-white dark:bg-gray-800 rounded-lg overflow-hidden 
                  transition-all duration-500 ${
                    isActive ? "shadow-2xl" : "shadow-md"
                  }`}
    >
      {/* 이미지 영역 */}
      <div 
        className="relative h-48 overflow-hidden cursor-pointer group"
        onClick={() => isActive && onImageClick(project.image)}
      >
        <img
          src={project.image}
          alt={project.title}
          className="w-full h-full object-cover transition-transform duration-300 
                     group-hover:scale-110"
        />
        {isActive && (
          <div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 
                         transition-colors duration-300" />
        )}
      </div>
 
      {/* 콘텐츠 영역 */}
      <div className="p-6">
        <h3 className="text-xl font-bold mb-2">{project.title}</h3>
        <p className="text-gray-600 dark:text-gray-300 text-sm mb-4">
          {project.description}
        </p>
 
        {/* 기술 스택 */}
        <div className="flex flex-wrap gap-2 mb-4">
          {project.technologies.map((tech, index) => (
            <span
              key={index}
              className="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 
                        rounded-md"
            >
              {tech}
            </span>
          ))}
        </div>
 
        {/* 링크 버튼들 */}
        {isActive && (
          <div className="flex gap-3">
            {project.githubUrl && (
              <a
                href={project.githubUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="text-sm text-blue-500 hover:text-blue-600 
                          transition-colors"
              >
 
> [!NOTE] GitHub →
              </a>
            )}
            {project.liveUrl && (
              <a
                href={project.liveUrl}
                target="_blank"
                rel="noopener noreferrer"
                className="text-sm text-green-500 hover:text-green-600 
                          transition-colors"
              >
                Live Demo →
              </a>
            )}
          </div>
        )}
      </div>
    </div>
  );
}

2. 3D 효과와 애니메이션

CSS Transform을 활용해 깊이감 있는 3D 효과를 구현했다. 중앙 카드는 원본 크기로, 양옆 카드는 축소하고 투명도를 조절해 자연스러운 원근감을 표현했다.

/* 사이드 카드 */
transform: scale(0.8);
opacity: 0.6;
 
/* 호버 시 */
opacity: 0.8;
transition: all 0.5s ease;

시각적 효과 구현

1. 그라디언트 텍스트

히어로 섹션의 타이틀에 그라디언트 효과를 적용했다.

import React from "react";
 
export default function HeroSection() {
  return (
    <section className="min-h-screen flex items-center justify-center">
      <div className="text-center">
        <h1 className="text-6xl md:text-8xl font-extrabold mb-6">
          <span className="bg-gradient-to-r from-blue-400 to-purple-400 
                         bg-clip-text text-transparent drop-shadow-lg">
            DEVELOPER
          </span>
        </h1>
 
        <p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8">
          Creating Digital Experiences
        </p>
 
        {/* 스크롤 유도 애니메이션 */}
        <div className="absolute bottom-10 left-1/2 transform -translate-x-1/2">
          <div className="animate-bounce">
            <svg
              className="w-6 h-6 text-gray-400"
              fill="none"
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth="2"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path d="M19 14l-7 7m0 0l-7-7m7 7V3"></path>
            </svg>
          </div>
        </div>
      </div>
    </section>
  );
}

2. 로딩 상태와 전환 효과

부드러운 전환을 위해 CSS 트랜지션과 애니메이션을 활용했다.

// 페이드인 애니메이션
const [mounted, setMounted] = useState(false);
 
useEffect(() => {
  setMounted(true);
}, []);
 
return (
  <div className={`transition-opacity duration-1000 ${
    mounted ? 'opacity-100' : 'opacity-0'
  }`}>
    {/* 콘텐츠 */}
  </div>
);

성능 최적화 전략

1. 이벤트 디바운싱

스크롤 이벤트는 매우 자주 발생하므로 디바운싱으로 성능을 최적화했다.

const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
 
if (scrollTimeoutRef.current) {
  clearTimeout(scrollTimeoutRef.current);
}
 
scrollTimeoutRef.current = setTimeout(() => {
  scrollToProjects();
}, 50);

2. 조건부 이벤트 리스너

필요한 경우에만 이벤트 리스너를 등록하고, 컴포넌트 언마운트 시 정리했다.

useEffect(() => {
  // 이벤트 리스너 등록
  window.addEventListener("wheel", handleWheel, { passive: false });
 
  // 클린업
  return () => {
    window.removeEventListener("wheel", handleWheel);
  };
}, [isScrolling]);

3. 하드웨어 가속 활용

CSS transformwill-change 속성을 사용해 GPU 가속을 활용했다.

.carousel-card {
  will-change: transform, opacity;
  transform: translateZ(0); /* GPU 레이어 생성 */
}

접근성 고려사항

1. 키보드 네비게이션

마우스 없이도 모든 기능을 사용할 수 있도록 키보드 지원을 추가했다.

  • 좌우 화살표로 프로젝트 전환
  • Tab 키로 포커스 이동
  • Enter 키로 선택

2. ARIA 레이블

스크린 리더 사용자를 위한 적절한 ARIA 레이블을 추가했다.

<button
  onClick={prevProject}
  aria-label="Previous project"
>
  <ChevronLeft className="h-5 w-5" />
</button>

3. 모션 감소 설정

@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

결과

image

  • 스크롤만으로 부드럽게 섹션이 전환된다
  • 모든 디바이스에서 최적화된 인터페이스를 제공한다

반응형 디자인, 성능 최적화, 접근성까지 모두 고려하면서도 시각적으로 매력적인 인터페이스를 구현하는 과정이 재미있었고, 동시에 값진 경험이었다.

📝 Note

이 프로젝트의 모든 소스 코드는 GitHub에 공개되어 있습니다. 코드 품질 개선이나 새로운 기능 제안에 대한 피드백은 언제나 환영합니다.