👏

React + Framer Motionで作るモダンなインタラクティブタイムライン

に公開

React + Framer Motion で作るモダンなインタラクティブタイムライン

こんにちは!今回は、ReactFramer Motionを使って、プロフィールサイトに最適なインタラクティブタイムラインを作成する方法をご紹介します。

🎯 完成イメージ

  • クリック可能なタイムラインイベント
  • 美しいアニメーション効果
  • ガラスモーフィズムデザイン
  • ライト/ダークモード切り替え
  • レスポンシブ対応
  • プロフィール情報の統合表示

🛠 使用技術

  • React 18 + TypeScript
  • Framer Motion - アニメーションライブラリ
  • Lucide React - アイコンライブラリ
  • CSS3 - モダンスタイリング(ガラスモーフィズム)

📋 前提条件

開発環境のバージョンを確認しましょう:

# バージョン確認
node --version  # v20.17.0以上推奨
npm --version   # 11.6.2以上推奨

🚀 プロジェクトセットアップ

1. React アプリの作成

# TypeScriptテンプレートでReactアプリを作成
npx create-react-app interactive-timeline --template typescript

# プロジェクトディレクトリに移動
cd interactive-timeline

2. 必要なライブラリのインストール

# アニメーションとアイコンライブラリをインストール
npm install framer-motion lucide-react

💻 実装

型定義の作成

まず、タイムラインイベントの型を定義します。

src/types/timeline.ts

export interface TimelineEvent {
  id: string;
  title: string;
  description: string;
  date: string;
  category: "work" | "education" | "project" | "achievement";
  icon?: string;
  image?: string;
  tags?: string[];
}

export interface TimelineProps {
  events: TimelineEvent[];
  orientation?: "vertical" | "horizontal";
  theme?: "light" | "dark";
}

サンプルデータの作成

src/data/sampleEvents.ts

import { TimelineEvent } from "../types/timeline";

export const sampleEvents: TimelineEvent[] = [
  {
    id: "1",
    title: "🚀 エンジニアとして入社",
    description:
      "PHP,React,C#を使用したWebアプリケーション開発に従事。モダンなフロントエンド技術でユーザー体験を向上させる開発に取り組んでいます。",
    date: "2024年4月",
    category: "work",
    tags: ["React", "TypeScript", "Next.js", "Vercel", "Tailwind CSS"],
    image:
      "https://images.unsplash.com/photo-1522202176988-66273c2fd55f?w=400&h=200&fit=crop",
  },
  {
    id: "2",
    title: "🎓 24卒として卒業",
    description:
      "コンピューターサイエンス学士号を取得。データ構造とアルゴリズム、ソフトウェア工学を学習。卒業研究では機械学習を使った画像認識システムを開発しました。",
    date: "2024年3月",
    category: "education",
    tags: ["24卒", "機械学習", "Python", "TensorFlow", "Computer Vision"],
    image:
      "https://images.unsplash.com/photo-1523050854058-8df90110c9f1?w=400&h=200&fit=crop",
  },
  {
    id: "3",
    title: "🛍️ Eコマースサイト開発",
    description:
      "Next.js、Prisma、PostgreSQLを使用したフルスタックEコマースサイトを個人開発。Stripe決済、在庫管理、認証機能を実装。Vercelでデプロイ。",
    date: "2023年12月",
    category: "project",
    tags: ["Next.js", "Prisma", "PostgreSQL", "Stripe", "Vercel"],
    image:
      "https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?w=400&h=200&fit=crop",
  },
  // 他のイベントも同様に定義...
];

メインタイムラインコンポーネント

src/components/Timeline.tsx

import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
  Calendar,
  Briefcase,
  GraduationCap,
  Code,
  Award,
  ChevronDown,
} from "lucide-react";
import { TimelineEvent, TimelineProps } from "../types/timeline";
import "./Timeline.css";

const Timeline: React.FC<TimelineProps> = ({
  events,
  orientation = "vertical",
  theme = "light",
}) => {
  const [selectedEvent, setSelectedEvent] = useState<string | null>(null);
  const [expandedEvent, setExpandedEvent] = useState<string | null>(null);

  const getIcon = (category: TimelineEvent["category"]) => {
    switch (category) {
      case "work":
        return <Briefcase size={20} />;
      case "education":
        return <GraduationCap size={20} />;
      case "project":
        return <Code size={20} />;
      case "achievement":
        return <Award size={20} />;
      default:
        return <Calendar size={20} />;
    }
  };

  const getCategoryColor = (category: TimelineEvent["category"]) => {
    switch (category) {
      case "work":
        return "#3b82f6"; // blue
      case "education":
        return "#10b981"; // green
      case "project":
        return "#f59e0b"; // yellow
      case "achievement":
        return "#ef4444"; // red
      default:
        return "#6b7280"; // gray
    }
  };

  const handleEventClick = (eventId: string) => {
    setSelectedEvent(selectedEvent === eventId ? null : eventId);
    setExpandedEvent(expandedEvent === eventId ? null : eventId);
  };

  // アニメーション設定
  const containerVariants = {
    hidden: { opacity: 0 },
    visible: {
      opacity: 1,
      transition: {
        staggerChildren: 0.1,
      },
    },
  };

  const itemVariants = {
    hidden: {
      opacity: 0,
      y: 20,
      scale: 0.95,
    },
    visible: {
      opacity: 1,
      y: 0,
      scale: 1,
      transition: {
        duration: 0.5,
        ease: [0.25, 0.46, 0.45, 0.94] as const,
      },
    },
  };

  const expandVariants = {
    collapsed: {
      height: 0,
      opacity: 0,
      transition: {
        duration: 0.3,
        ease: [0.42, 0, 0.58, 1] as const,
      },
    },
    expanded: {
      height: "auto",
      opacity: 1,
      transition: {
        duration: 0.3,
        ease: [0.42, 0, 0.58, 1] as const,
      },
    },
  };

  return (
    <div className={`timeline-container ${theme} ${orientation}`}>
      <motion.div
        className="timeline"
        variants={containerVariants}
        initial="hidden"
        animate="visible"
      >
        <div className="timeline-line" />

        {events.map((event, index) => (
          <motion.div
            key={event.id}
            className={`timeline-item ${
              selectedEvent === event.id ? "selected" : ""
            }`}
            variants={itemVariants}
            whileHover={{
              scale: 1.02,
              transition: { duration: 0.2 },
            }}
            onClick={() => handleEventClick(event.id)}
          >
            <div className="timeline-marker">
              <motion.div
                className="timeline-icon"
                style={{ backgroundColor: getCategoryColor(event.category) }}
                whileHover={{
                  scale: 1.1,
                  rotate: 5,
                  transition: { duration: 0.2 },
                }}
                whileTap={{ scale: 0.95 }}
              >
                {getIcon(event.category)}
              </motion.div>
            </div>

            <motion.div className="timeline-content" layout>
              <div className="timeline-header">
                <h3 className="timeline-title">{event.title}</h3>
                <span className="timeline-date">{event.date}</span>
                <motion.div
                  className="expand-icon"
                  animate={{ rotate: expandedEvent === event.id ? 180 : 0 }}
                  transition={{ duration: 0.2 }}
                >
                  <ChevronDown size={16} />
                </motion.div>
              </div>

              <p className="timeline-description">{event.description}</p>

              <AnimatePresence>
                {expandedEvent === event.id && (
                  <motion.div
                    className="timeline-details"
                    variants={expandVariants}
                    initial="collapsed"
                    animate="expanded"
                    exit="collapsed"
                  >
                    {event.image && (
                      <motion.img
                        src={event.image}
                        alt={event.title}
                        className="timeline-image"
                        initial={{ opacity: 0, scale: 0.8 }}
                        animate={{ opacity: 1, scale: 1 }}
                        transition={{ delay: 0.1 }}
                      />
                    )}

                    {event.tags && (
                      <div className="timeline-tags">
                        {event.tags.map((tag, tagIndex) => (
                          <motion.span
                            key={tagIndex}
                            className="timeline-tag"
                            style={{
                              backgroundColor: getCategoryColor(event.category),
                            }}
                            initial={{ opacity: 0, x: -10 }}
                            animate={{ opacity: 1, x: 0 }}
                            transition={{ delay: 0.1 + tagIndex * 0.05 }}
                          >
                            {tag}
                          </motion.span>
                        ))}
                      </div>
                    )}

                    <motion.div
                      className="timeline-category"
                      initial={{ opacity: 0 }}
                      animate={{ opacity: 1 }}
                      transition={{ delay: 0.2 }}
                    >
                      <span
                        className="category-badge"
                        style={{
                          backgroundColor: getCategoryColor(event.category),
                        }}
                      >
                        {event.category.toUpperCase()}
                      </span>
                    </motion.div>
                  </motion.div>
                )}
              </AnimatePresence>
            </motion.div>
          </motion.div>
        ))}
      </motion.div>
    </div>
  );
};

export default Timeline;

プロフィール統合 App.tsx

src/App.tsx

import React, { useState } from "react";
import Timeline from "./components/Timeline";
import { sampleEvents } from "./data/sampleEvents";
import {
  Moon,
  Sun,
  RotateCcw,
  Github,
  ExternalLink,
  Mail,
  MapPin,
} from "lucide-react";
import "./App.css";

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  const [orientation, setOrientation] = useState<"vertical" | "horizontal">(
    "vertical"
  );

  const toggleTheme = () => {
    setTheme(theme === "light" ? "dark" : "light");
  };

  const toggleOrientation = () => {
    setOrientation(orientation === "vertical" ? "horizontal" : "vertical");
  };

  return (
    <div className={`App ${theme}`}>
      <header className="app-header">
        <div className="profile-section">
          <div className="profile-avatar">
            <div className="avatar-circle">
              <span className="avatar-text"></span>
            </div>
            <div className="status-indicator"></div>
          </div>

          <div className="profile-info">
            <h1 className="profile-name"></h1>
            <p className="profile-title">24卒 フロントエンドエンジニア</p>
            <div className="profile-location">
              <MapPin size={16} />
              <span>Tokyo, Japan</span>
            </div>
          </div>
        </div>

        <div className="profile-links">
          <a
            href="https://github.com/yourusername"
            className="profile-link"
            target="_blank"
            rel="noopener noreferrer"
          >
            <Github size={18} />
            <span>GitHub</span>
            <ExternalLink size={14} />
          </a>
          <a
            href="https://zenn.dev/yourusername"
            className="profile-link"
            target="_blank"
            rel="noopener noreferrer"
          >
            <span className="zenn-icon">Z</span>
            <span>Zenn</span>
            <ExternalLink size={14} />
          </a>
          <a href="mailto:contact@example.com" className="profile-link">
            <Mail size={18} />
            <span>Contact</span>
          </a>
        </div>

        <div className="controls">
          <button onClick={toggleTheme} className="control-btn">
            {theme === "light" ? <Moon size={20} /> : <Sun size={20} />}
            {theme === "light" ? "ダーク" : "ライト"}モード
          </button>

          <button onClick={toggleOrientation} className="control-btn">
            <RotateCcw size={20} />
            {orientation === "vertical" ? "横向き" : "縦向き"}表示
          </button>
        </div>
      </header>

      <main>
        <Timeline
          events={sampleEvents}
          orientation={orientation}
          theme={theme}
        />
      </main>

      <footer className="app-footer">
        <div className="footer-content">
          <p>© 2024 ポ - Interactive Timeline</p>
          <div className="footer-tech">
            <span>Built with React + TypeScript + Framer Motion</span>
          </div>
        </div>
      </footer>
    </div>
  );
}

export default App;

モダンなスタイリング

src/components/Timeline.css(抜粋):

/* ガラスモーフィズム効果 */
.timeline-content {
  margin-left: 6rem;
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(20px);
  border-radius: 16px;
  padding: 2rem;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
  border: 1px solid rgba(255, 255, 255, 0.2);
  transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
  overflow: hidden;
  position: relative;
}

/* グラデーションボーダー */
.timeline-content::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 3px;
  background: linear-gradient(90deg, #3b82f6, #10b981, #f59e0b, #ef4444);
  border-radius: 16px 16px 0 0;
}

/* ホバーエフェクト */
.timeline-content:hover {
  box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
  transform: translateY(-4px) scale(1.02);
}

/* モダンなタグデザイン */
.timeline-tag {
  padding: 0.4rem 1rem;
  border-radius: 25px;
  font-size: 0.75rem;
  font-weight: 600;
  color: white;
  background: linear-gradient(
    135deg,
    rgba(59, 130, 246, 0.8),
    rgba(16, 185, 129, 0.8)
  );
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.2);
  transition: all 0.3s ease;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.timeline-tag:hover {
  transform: translateY(-2px) scale(1.05);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

src/App.css(プロフィールセクション):

/* プロフィールアバター */
.avatar-circle {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: linear-gradient(135deg, #3b82f6, #10b981);
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8px 32px rgba(59, 130, 246, 0.3);
  border: 3px solid rgba(255, 255, 255, 0.2);
}

/* ステータスインジケーター */
.status-indicator {
  position: absolute;
  bottom: 5px;
  right: 5px;
  width: 16px;
  height: 16px;
  background: #10b981;
  border-radius: 50%;
  border: 3px solid rgba(255, 255, 255, 0.9);
  animation: pulse 2s infinite;
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

/* プロフィールリンク */
.profile-link {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1.25rem;
  border-radius: 50px;
  background: rgba(255, 255, 255, 0.1);
  color: inherit;
  text-decoration: none;
  font-weight: 500;
  transition: all 0.3s ease;
  backdrop-filter: blur(10px);
  border: 1px solid rgba(255, 255, 255, 0.2);
}

.profile-link:hover {
  background: rgba(255, 255, 255, 0.2);
  transform: translateY(-2px);
  box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}

🎮 実行

# 開発サーバーを起動
npm start

ブラウザで http://localhost:3000 にアクセスすると、美しいインタラクティブタイムラインが表示されます!

🔧 主要機能の解説

1. Framer Motion アニメーション

// スタガードアニメーション
const containerVariants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.1, // 子要素を順次アニメーション
    },
  },
};

// 個別要素のアニメーション
const itemVariants = {
  hidden: { opacity: 0, y: 20, scale: 0.95 },
  visible: {
    opacity: 1,
    y: 0,
    scale: 1,
    transition: {
      duration: 0.5,
      ease: [0.25, 0.46, 0.45, 0.94], // カスタムイージング
    },
  },
};

2. ガラスモーフィズムデザイン

.timeline-content {
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(20px); /* ガラス効果 */
  border: 1px solid rgba(255, 255, 255, 0.2);
}

3. インタラクティブ機能

  • クリック展開: イベントをクリックして詳細表示
  • ホバーエフェクト: マウスオーバーでスケール変更
  • テーマ切り替え: ライト/ダークモード
  • レイアウト変更: 縦向き/横向き表示

🎨 カスタマイズのポイント

色の変更

const getCategoryColor = (category: string) => {
  switch (category) {
    case "work":
      return "#your-color";
    case "education":
      return "#your-color";
    // ...
  }
};

アニメーション調整

// アニメーション速度の変更
transition: {
  duration: 0.8;
} // デフォルト: 0.5

// イージングの変更
ease: [0.4, 0, 0.2, 1]; // Material Design easing

プロフィール情報の更新

<h1 className="profile-name">あなたの名前</h1>
<p className="profile-title">あなたの職業</p>

📱 レスポンシブ対応

@media (max-width: 768px) {
  .profile-section {
    flex-direction: column;
    gap: 1rem;
  }

  .timeline-content {
    margin-left: 4.5rem;
    padding: 1rem;
  }
}

🚀 デプロイ

Vercel でのデプロイ

# ビルド
npm run build

# Vercel CLIでデプロイ
npx vercel --prod

Netlify でのデプロイ

# ビルド
npm run build

# build フォルダをNetlifyにドラッグ&ドロップ

🎯 まとめ

Framer Motion を使うことで、複雑なアニメーションを宣言的に実装できました。

メリット

  • 豊富なアニメーション: スタガード、レイアウト、ジェスチャー対応
  • パフォーマンス: GPU 加速による滑らかなアニメーション
  • TypeScript 対応: 型安全なアニメーション開発
  • 直感的な API: React ライクな宣言的記述

応用例

  • ポートフォリオサイト
  • 企業の沿革ページ
  • プロジェクトの進捗表示
  • 学習履歴の可視化

パフォーマンス最適化

  • layout プロパティでレイアウトアニメーション
  • AnimatePresence で要素の出入りを制御
  • whileHover / whileTap でインタラクション

ぜひ、あなたのプロフィールサイトにこのインタラクティブタイムラインを取り入れて、訪問者に印象的な体験を提供してみてください!

🔗 参考リンク


この記事が役に立ったら、ぜひ「いいね」をお願いします!⏰✨

Discussion