【React + TypeScript】文系出身者がこだわった、なめらかに動くポートフォリオサイトの作り方

# 【React + TypeScript】文系出身者がこだわった、なめらかに動くポートフォリオサイトの作り方

こんにちは！文系出身でプログラミング歴2年の駆け出しエンジニアです。この記事では、自分の技術力のアウトプットとして作成したポートフォリオサイトについて、特にこだわったデザインや滑らかなアニメーションの実装方法を中心に解説します。

## はじめに：なぜポートフォリオサイトを作ったのか

法学部出身の私がプログラミングを始めたのは2年前。独学と仕事を通じて技術を身につけてきましたが、「自分の技術力を一度きちんとアウトプットしたい」という思いが強くなり、ポートフォリオサイト制作に挑戦しました。

特に重視したのは以下の点です：

- モダンな技術スタックの採用
- ユーザーが心地よいと感じるスムーズな動き
- レスポンシブ対応
- 直感的なUI/UX

## 技術スタック

- フロントエンド
  - React 19.0.0
  - TypeScript 5.7.2
  - Vite 6.2.1
- スタイリング
  - CSS Variables（カスタムプロパティ）
- デプロイ
  - Firebase Hosting

## サイト構成

基本的なセクション構成は以下の通りです：

1. ヒーローセクション
2. 自己紹介セクション
3. スキルセクション
4. プロジェクトセクション
5. コンタクトセクション
6. フッター

## こだわり①：CSS変数を活用した一貫したデザインシステム

デザインの一貫性を保つために、CSS変数（カスタムプロパティ）を活用しました。これにより、色やサイズ、アニメーション速度などを簡単に統一でき、変更も容易になります。

```css
:root {
  /* メインカラーパレット */
  --primary-color: #5e60ce;
  --primary-light: #7a7de3;
  --primary-dark: #4c4da3;
  --secondary-color: #64dfdf;
  --accent-color: #ff7eee;

  /* テキストカラー */
  --text-primary: #2b2c34;
  --text-secondary: #555;
  --text-light: #777;
  --text-on-dark: #f8f9fa;

  /* 背景カラー */
  --bg-primary: #ffffff;
  --bg-secondary: #f8f9fa;
  --bg-tertiary: #edf2f7;
  --bg-dark: #2b2c34;

  /* トランジション */
  --transition-fast: all 0.2s ease;
  --transition-normal: all 0.3s ease;
  --transition-slow: all 0.5s ease;
  
  /* 他にもスペーシングやボーダーラディウスなど */
}
```

これらの変数を使うことで、例えばボタンのスタイルを以下のように簡潔に定義できます：

```css
.btn {
  padding: var(--space-3) var(--space-6);
  border-radius: var(--border-radius-full);
  transition: var(--transition-normal);
  /* 他のスタイル */
}
```

## こだわり②：スムーズなスクロールと遷移アニメーション

ユーザーエクスペリエンスで最も重視したのが、サイト内の動きの滑らかさです。

### スムーズスクロール

HTML全体にスムーズスクロールを適用：

```css
html {
  scroll-behavior: smooth;
}
```

### アニメーションのタイミング調整

特に気をつけたのは、アニメーションのタイミングとイージング（加速・減速）の調整です。例えば、ヒーローセクションの要素をフェードインさせる際には：

```css
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.hero-text {
  animation: fadeIn 0.8s ease forwards;
}

.hero-image-container {
  animation: fadeIn 1s ease 0.3s forwards;
  opacity: 0;
}
```

ここでポイントなのは、テキストを先に表示し（0.8秒）、少し遅れて（0.3秒後）画像を表示する演出です。こうすることで、ユーザーの視線の流れをコントロールし、自然な印象を与えることができます。

### 浮動するカードエフェクト

ヒーローセクションでは「経験年数」や「プロジェクト数」を示すカードが浮遊するアニメーションを実装しました：

```css
.floating-card {
  position: absolute;
  display: flex;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--border-radius-md);
  background-color: var(--bg-primary);
  box-shadow: var(--box-shadow-md);
  z-index: 2;
}

.card-experience {
  top: 10%;
  left: -15%;
  animation: floatCard 3s ease-in-out infinite;
}

.card-projects {
  bottom: 15%;
  right: -10%;
  animation: floatCard 3s ease-in-out 1.5s infinite;
}

@keyframes floatCard {
  0%, 100% {
    transform: translateY(0);
  }
  50% {
    transform: translateY(-10px);
  }
}
```

このように時間差を付けて浮遊させることで、自然で有機的な動きを演出しています。

## こだわり③：インタラクティブな要素

### フィルタリング機能

プロジェクトセクションでは、カテゴリ別にフィルタリングできる機能を実装しました：

```tsx
// Projects.tsxの一部抜粋
const [filter, setFilter] = useState<FilterType>('all');
const filteredProjects = filter === 'all' 
  ? projects 
  : projects.filter(project => project.category === filter);

// フィルターボタン
<div className="filter-buttons">
  <button 
    className={`filter-btn ${filter === 'all' ? 'active' : ''}`}
    onClick={() => setFilter('all')}
  >
    すべて
    <span className="filter-count">{projects.length}</span>
  </button>
  {/* 他のフィルターボタン */}
</div>

// フィルター適用後のプロジェクト表示
<div className="projects-grid">
  {filteredProjects.map(project => (
    <div className="project-card" key={project.id}>
      {/* プロジェクトカードの内容 */}
    </div>
  ))}
</div>
```

### モーダル表示

プロジェクトカードをクリックすると詳細がモーダルで表示される機能も実装しました：

```tsx
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
const [modalOpen, setModalOpen] = useState<boolean>(false);

const handleProjectClick = (project: Project) => {
  setSelectedProject(project);
  setModalOpen(true);
  document.body.style.overflow = 'hidden'; // スクロール防止
};

const closeModal = () => {
  setModalOpen(false);
  document.body.style.overflow = 'auto'; // スクロール復活
};
```

モーダルのアニメーションもこだわりました：

```css
.project-modal {
  animation: modalFadeIn 0.3s ease;
}

@keyframes modalFadeIn {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
```

## こだわり④：レスポンシブデザイン

様々な画面サイズに対応するため、メディアクエリを活用しました：

```css
/* 基本レイアウト */
.hero-content {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-12);
}

/* タブレットサイズ */
@media screen and (max-width: 992px) {
  .hero-content {
    grid-template-columns: 1fr;
    text-align: center;
  }
  
  .hero-image-container {
    order: 1; /* 画像を上に */
  }
  
  .hero-text {
    order: 2; /* テキストを下に */
  }
}

/* スマートフォンサイズ */
@media screen and (max-width: 576px) {
  html {
    font-size: 12px; /* 基本フォントサイズを小さく */
  }
  
  .section-title {
    font-size: 1.8rem;
  }
  
  /* 他の調整 */
}
```

## 苦労した点と学び

### State管理の最適化

特にプロジェクトセクションでは、フィルタリングとモーダル表示の状態管理を組み合わせる必要があり、初めは複雑になってしまいました。ですが、適切にステートを分割し、関数をシンプルにすることで可読性を向上させました。

### CSSアニメーションのタイミング調整

複数の要素が連動するアニメーションのタイミング調整は、思った以上に時間がかかりました。デバイスによって表示が異なる場合もあり、様々な環境でテストする必要がありました。

### フォームのバリデーション

コンタクトフォームのバリデーションとユーザー体験の両立に苦労しました。エラーメッセージの表示タイミングや、フォーカス時の挙動など、細かい部分まで調整しています：

```tsx
// フォーム入力項目のフォーカス状態管理
const [isFocused, setIsFocused] = useState({
  name: false,
  email: false,
  subject: false,
  message: false
});

const handleFocus = (field) => {
  setIsFocused(prev => ({
    ...prev,
    [field]: true
  }));
};

const handleBlur = (field) => {
  if (!formData[field]) {
    setIsFocused(prev => ({
      ...prev,
      [field]: false
    }));
  }
};
```

## 今後の改善点

現在も継続的に改善を行っていますが、主な今後の課題は：

1. さらなるプロジェクト追加（現在は一部仮データ）
2. ダークモード実装
3. 多言語対応
4. パフォーマンス最適化

## まとめ

文系出身のエンジニアとして、デザインと技術の両面からポートフォリオサイトを作り上げる過程は非常に勉強になりました。特にCSSアニメーションと状態管理の組み合わせは、見た目だけでなくユーザー体験という観点からも深く考えるきっかけになりました。

何か質問やフィードバックがあれば、コメントいただけると嬉しいです！

## 参考リソース

- [MDN - CSS アニメーションガイド](https://developer.mozilla.org/ja/docs/Web/CSS/CSS_animations/Using_CSS_animations)
- [React 公式ドキュメント - フック API リファレンス](https://ja.react.dev/reference/react)