T-CREATOR

SolidJS でアニメーションを実装するテクニック

SolidJS でアニメーションを実装するテクニック

モダンな Web アプリケーションにおいて、アニメーションはユーザーエクスペリエンスを向上させる重要な要素です。SolidJS は高いパフォーマンスを誇るリアクティブなフレームワークとして注目を集めていますが、アニメーション実装においても素晴らしい特性を発揮します。

本記事では、SolidJS でアニメーションを実装する際の基本的な手法から応用テクニックまで、実際のコード例とともに詳しく解説いたします。初心者の方でも理解しやすいよう、段階的にアプローチしていきましょう。

背景

近年の Web アプリケーション開発において、アニメーションは単なる装飾要素ではなく、ユーザビリティを大幅に向上させる重要な機能として位置付けられています。適切に実装されたアニメーションは、ユーザーの操作に対する直感的なフィードバックを提供し、アプリケーション全体の使いやすさを向上させます。

SolidJS は、React や Vue とは異なるリアクティブなアプローチを採用したフレームワークです。細粒度のリアクティビティにより、従来の Virtual DOM ベースのフレームワークと比較して、圧倒的なパフォーマンスを実現しています。

この高いパフォーマンス特性は、アニメーション実装においても大きなメリットをもたらします。60FPS を維持しながらスムーズなアニメーションを実現することで、ユーザーに快適な体験を提供できるのです。

特徴SolidJSReactVue
リアクティビティ細粒度Virtual DOMVirtual DOM
アニメーション性能
メモリ使用量
学習コスト

課題

SolidJS でアニメーションを実装する際には、いくつかの課題に直面することがあります。これらの課題を理解し、適切に対処することが、効果的なアニメーション実装の鍵となります。

従来のアニメーション手法の限界

多くの開発者が最初に試みるのは、CSS のみでアニメーションを実装することです。しかし、複雑な状態管理やユーザーインタラクションが絡む場合、CSS 単体では限界があります。

特に以下のような場面で問題が発生します:

  • 動的な値に基づくアニメーション制御
  • 複数の要素間での同期されたアニメーション
  • ユーザーの操作に応じたアニメーションの中断・再開
  • コンポーネントのマウント・アンマウント時のアニメーション

SolidJS 特有の課題

SolidJS のリアクティブシステムは非常に効率的ですが、アニメーション実装時には独特の注意点があります。

Signal 更新タイミングの課題

typescript// 問題のあるコード例
const [position, setPosition] = createSignal(0);

// 高頻度で更新されるとパフォーマンスに影響
const handleMouseMove = (e: MouseEvent) => {
  setPosition(e.clientX); // 毎フレーム実行される可能性
};

このコードでは、マウス移動のたびに Signal が更新され、不要な再レンダリングが発生する可能性があります。

アニメーション状態の管理

SolidJS では、アニメーション状態をどのように管理するかが重要な問題となります。従来の state ベースの管理では、アニメーション中の中間値を適切に処理できない場合があります。

エラー対応の難しさ

アニメーション実装時によく遭遇するエラーの一例:

javascriptTypeError: Cannot read properties of undefined (reading 'getBoundingClientRect')
    at AnimationComponent.tsx:15:32

このエラーは、DOM 要素への参照が適切に設定されていない場合に発生します。SolidJS のライフサイクルを理解していないと、このような問題に直面することが多いでしょう。

解決策

これらの課題を解決するため、SolidJS では複数のアニメーション手法を組み合わせて使用します。それぞれの手法には特徴があり、適切な場面で使い分けることが重要です。

CSS Transitions の活用

CSS Transitions は、最もシンプルで軽量なアニメーション手法です。SolidJS のリアクティブな state と組み合わせることで、効果的なアニメーションを実現できます。

以下は、ボタンのホバー効果を実装する基本的な例です:

typescriptimport { createSignal } from 'solid-js';
import './Button.css';

const AnimatedButton = () => {
  const [isHovered, setIsHovered] = createSignal(false);

  return (
    <button
      class={`animated-button ${
        isHovered() ? 'hovered' : ''
      }`}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      クリックしてください
    </button>
  );
};

対応する CSS ファイルは以下のように記述します:

css.animated-button {
  padding: 12px 24px;
  background-color: #3b82f6;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease-in-out;
  transform: scale(1);
}

.animated-button.hovered {
  background-color: #2563eb;
  transform: scale(1.05);
  box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3);
}

この実装では、SolidJS の Signal を使って状態を管理し、CSS の transition プロパティでスムーズなアニメーションを実現しています。

より高度な Transition 制御

複数のプロパティを個別に制御したい場合は、以下のような実装が効果的です:

typescriptimport { createSignal, onMount } from 'solid-js';

const AdvancedTransition = () => {
  const [position, setPosition] = createSignal({
    x: 0,
    y: 0,
  });
  const [isActive, setIsActive] = createSignal(false);
  let elementRef: HTMLDivElement | undefined;

  const handleClick = (e: MouseEvent) => {
    if (elementRef) {
      const rect = elementRef.getBoundingClientRect();
      setPosition({
        x: e.clientX - rect.left,
        y: e.clientY - rect.top,
      });
      setIsActive(true);

      // 0.5秒後に非アクティブ状態に戻す
      setTimeout(() => setIsActive(false), 500);
    }
  };

  return (
    <div
      ref={elementRef}
      class='transition-container'
      onClick={handleClick}
      style={{
        '--pos-x': `${position().x}px`,
        '--pos-y': `${position().y}px`,
        '--scale': isActive() ? '1.2' : '1',
      }}
    >
      <div class='animated-element'>
        クリック位置に反応します
      </div>
    </div>
  );
};

CSS Animations の実装

より複雑なアニメーションが必要な場合は、CSS Animations を使用します。キーフレームを定義することで、細かい制御が可能になります。

ローディングアニメーションの実装

typescriptimport { createSignal, createEffect } from 'solid-js';

const LoadingSpinner = () => {
  const [isLoading, setIsLoading] = createSignal(true);
  const [progress, setProgress] = createSignal(0);

  // プログレス更新のシミュレーション
  createEffect(() => {
    if (isLoading()) {
      const interval = setInterval(() => {
        setProgress((prev) => {
          const newProgress = prev + Math.random() * 10;
          if (newProgress >= 100) {
            setIsLoading(false);
            clearInterval(interval);
            return 100;
          }
          return newProgress;
        });
      }, 200);
    }
  });

  return (
    <div class='loading-container'>
      {isLoading() ? (
        <>
          <div class='spinner' />
          <div class='progress-bar'>
            <div
              class='progress-fill'
              style={{ width: `${progress()}%` }}
            />
          </div>
          <p>読み込み中... {Math.round(progress())}%</p>
        </>
      ) : (
        <div class='success-message'>✓ 読み込み完了!</div>
      )}
    </div>
  );
};

対応する CSS:

css@keyframes spin {
  from {
    transform: rotate(0deg);
  }
  to {
    transform: rotate(360deg);
  }
}

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

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid #f3f4f6;
  border-top: 4px solid #3b82f6;
  border-radius: 50%;
  animation: spin 1s linear infinite;
  margin: 0 auto 16px;
}

.progress-bar {
  width: 200px;
  height: 8px;
  background-color: #e5e7eb;
  border-radius: 4px;
  overflow: hidden;
  margin: 16px auto;
}

.progress-fill {
  height: 100%;
  background-color: #3b82f6;
  transition: width 0.3s ease-out;
}

.success-message {
  color: #059669;
  font-weight: bold;
  animation: pulse 0.5s ease-in-out;
}

Solid Transition の使用方法

SolidJS には、コンポーネントの表示・非表示時にアニメーションを適用するための<Transition>コンポーネントが用意されています。これを使用することで、マウント・アンマウント時のアニメーションを簡単に実装できます。

まず、必要なパッケージをインストールします:

bashyarn add solid-transition-group

基本的な Transition 実装

typescriptimport { createSignal } from 'solid-js';
import { Transition } from 'solid-transition-group';

const TransitionExample = () => {
  const [show, setShow] = createSignal(true);

  return (
    <div>
      <button onClick={() => setShow(!show())}>
        {show() ? '非表示' : '表示'}
      </button>

      <Transition name='fade'>
        {show() && (
          <div class='transition-content'>
            <h3>アニメーション付きコンテンツ</h3>
            <p>
              この要素は、表示・非表示切り替え時にフェードアニメーションが適用されます。
            </p>
          </div>
        )}
      </Transition>
    </div>
  );
};

Transition に対応する CSS:

css.fade-enter-active,
.fade-exit-active {
  transition: opacity 0.5s ease-in-out;
}

.fade-enter-from,
.fade-exit-to {
  opacity: 0;
}

.fade-enter-to,
.fade-exit-from {
  opacity: 1;
}

.transition-content {
  padding: 20px;
  background: linear-gradient(
    135deg,
    #667eea 0%,
    #764ba2 100%
  );
  color: white;
  border-radius: 12px;
  margin-top: 16px;
}

リストアイテムの動的 Transition

typescriptimport { createSignal, For } from 'solid-js';
import { TransitionGroup } from 'solid-transition-group';

interface TodoItem {
  id: number;
  text: string;
  completed: boolean;
}

const AnimatedTodoList = () => {
  const [todos, setTodos] = createSignal<TodoItem[]>([
    { id: 1, text: 'SolidJSを学習する', completed: false },
    {
      id: 2,
      text: 'アニメーションを実装する',
      completed: false,
    },
  ]);
  const [inputValue, setInputValue] = createSignal('');

  const addTodo = () => {
    if (inputValue().trim()) {
      const newTodo: TodoItem = {
        id: Date.now(),
        text: inputValue(),
        completed: false,
      };
      setTodos((prev) => [...prev, newTodo]);
      setInputValue('');
    }
  };

  const removeTodo = (id: number) => {
    setTodos((prev) =>
      prev.filter((todo) => todo.id !== id)
    );
  };

  return (
    <div class='todo-container'>
      <div class='input-section'>
        <input
          type='text'
          value={inputValue()}
          onInput={(e) =>
            setInputValue(e.currentTarget.value)
          }
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder='新しいタスクを入力'
        />
        <button onClick={addTodo}>追加</button>
      </div>

      <TransitionGroup name='list-item'>
        <For each={todos()}>
          {(todo) => (
            <div class='todo-item' data-id={todo.id}>
              <span
                class={todo.completed ? 'completed' : ''}
              >
                {todo.text}
              </span>
              <button
                class='remove-btn'
                onClick={() => removeTodo(todo.id)}
              >
                削除
              </button>
            </div>
          )}
        </For>
      </TransitionGroup>
    </div>
  );
};

カスタムアニメーションフック

より柔軟なアニメーション制御が必要な場合は、カスタムフックを作成します。これにより、アニメーションロジックを再利用可能な形で抽出できます。

基本的なアニメーションフック

typescriptimport {
  createSignal,
  createEffect,
  onCleanup,
} from 'solid-js';

interface AnimationOptions {
  duration?: number;
  easing?: (t: number) => number;
  autoStart?: boolean;
}

// カスタムアニメーションフック
export const useAnimation = (
  from: number,
  to: number,
  options: AnimationOptions = {}
) => {
  const {
    duration = 300,
    easing = (t) => t,
    autoStart = false,
  } = options;
  const [value, setValue] = createSignal(from);
  const [isAnimating, setIsAnimating] = createSignal(false);

  let animationId: number | undefined;
  let startTime: number;

  const animate = () => {
    if (animationId) {
      cancelAnimationFrame(animationId);
    }

    setIsAnimating(true);
    startTime = performance.now();

    const step = (currentTime: number) => {
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      const easedProgress = easing(progress);

      const currentValue =
        from + (to - from) * easedProgress;
      setValue(currentValue);

      if (progress < 1) {
        animationId = requestAnimationFrame(step);
      } else {
        setIsAnimating(false);
        animationId = undefined;
      }
    };

    animationId = requestAnimationFrame(step);
  };

  // クリーンアップ処理
  onCleanup(() => {
    if (animationId) {
      cancelAnimationFrame(animationId);
    }
  });

  // 自動開始オプション
  if (autoStart) {
    animate();
  }

  return {
    value,
    isAnimating,
    start: animate,
    stop: () => {
      if (animationId) {
        cancelAnimationFrame(animationId);
        setIsAnimating(false);
      }
    },
  };
};

このフックを使用した実装例:

typescriptimport { useAnimation } from './useAnimation';

const CustomAnimationExample = () => {
  // イージング関数の定義
  const easeOutBounce = (t: number): number => {
    if (t < 1 / 2.75) {
      return 7.5625 * t * t;
    } else if (t < 2 / 2.75) {
      return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75;
    } else if (t < 2.5 / 2.75) {
      return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375;
    } else {
      return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375;
    }
  };

  const animation = useAnimation(0, 300, {
    duration: 1000,
    easing: easeOutBounce,
  });

  return (
    <div class='custom-animation-demo'>
      <div
        class='animated-box'
        style={{
          transform: `translateX(${animation.value()}px)`,
          'background-color': animation.isAnimating()
            ? '#ef4444'
            : '#3b82f6',
        }}
      >
        移動する箱
      </div>

      <div class='controls'>
        <button
          onClick={() => animation.start()}
          disabled={animation.isAnimating()}
        >
          {animation.isAnimating()
            ? 'アニメーション中...'
            : 'アニメーション開始'}
        </button>
        <button onClick={() => animation.stop()}>
          停止
        </button>
      </div>
    </div>
  );
};

複数値対応のアニメーションフック

typescriptinterface MultiValueAnimationOptions<T> {
  duration?: number;
  easing?: (t: number) => number;
  onComplete?: () => void;
}

export const useMultiValueAnimation = <
  T extends Record<string, number>
>(
  from: T,
  to: T,
  options: MultiValueAnimationOptions<T> = {}
) => {
  const {
    duration = 300,
    easing = (t) => t,
    onComplete,
  } = options;
  const [values, setValues] = createSignal<T>(from);
  const [isAnimating, setIsAnimating] = createSignal(false);

  let animationId: number | undefined;

  const animate = () => {
    if (animationId) {
      cancelAnimationFrame(animationId);
    }

    setIsAnimating(true);
    const startTime = performance.now();
    const keys = Object.keys(from) as (keyof T)[];

    const step = (currentTime: number) => {
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / duration, 1);
      const easedProgress = easing(progress);

      const currentValues = {} as T;

      keys.forEach((key) => {
        const fromValue = from[key];
        const toValue = to[key];
        currentValues[key] =
          fromValue + (toValue - fromValue) * easedProgress;
      });

      setValues(currentValues);

      if (progress < 1) {
        animationId = requestAnimationFrame(step);
      } else {
        setIsAnimating(false);
        animationId = undefined;
        onComplete?.();
      }
    };

    animationId = requestAnimationFrame(step);
  };

  onCleanup(() => {
    if (animationId) {
      cancelAnimationFrame(animationId);
    }
  });

  return {
    values,
    isAnimating,
    start: animate,
  };
};

具体例

ここからは、実際の Web アプリケーションでよく使われるアニメーションパターンを具体的に実装していきます。これらの例を参考に、あなたのプロジェクトに適したアニメーションを実装してください。

フェードインアニメーション

フェードインアニメーションは、コンテンツを徐々に表示する際に使用される最も基本的なアニメーションです。ユーザーの注意を自然に引きつける効果があります。

基本的なフェードイン実装

typescriptimport { createSignal, onMount } from 'solid-js';

const FadeInComponent = () => {
  const [isVisible, setIsVisible] = createSignal(false);
  let elementRef: HTMLDivElement | undefined;

  // コンポーネントマウント時にフェードイン開始
  onMount(() => {
    // DOM更新後にアニメーションを開始するために少し遅延
    setTimeout(() => setIsVisible(true), 50);
  });

  return (
    <div
      ref={elementRef}
      class={`fade-container ${
        isVisible() ? 'visible' : ''
      }`}
    >
      <h2>フェードインコンテンツ</h2>
      <p>
        このコンテンツは、ページ読み込み時にフェードインアニメーションで表示されます。
      </p>
      <div class='feature-grid'>
        <div class='feature-item'>機能1</div>
        <div class='feature-item'>機能2</div>
        <div class='feature-item'>機能3</div>
      </div>
    </div>
  );
};

対応する CSS:

css.fade-container {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}

.fade-container.visible {
  opacity: 1;
  transform: translateY(0);
}

.feature-grid {
  display: grid;
  grid-template-columns: repeat(
    auto-fit,
    minmax(200px, 1fr)
  );
  gap: 16px;
  margin-top: 24px;
}

.feature-item {
  padding: 20px;
  background: linear-gradient(
    135deg,
    #667eea 0%,
    #764ba2 100%
  );
  color: white;
  border-radius: 8px;
  text-align: center;
  transition: transform 0.3s ease;
}

.fade-container.visible .feature-item {
  animation: slideInUp 0.8s ease-out forwards;
}

.fade-container.visible .feature-item:nth-child(1) {
  animation-delay: 0.1s;
}
.fade-container.visible .feature-item:nth-child(2) {
  animation-delay: 0.2s;
}
.fade-container.visible .feature-item:nth-child(3) {
  animation-delay: 0.3s;
}

@keyframes slideInUp {
  from {
    opacity: 0;
    transform: translateY(30px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

スクロール連動フェードイン

typescriptimport { createSignal, onMount, onCleanup } from 'solid-js';

const ScrollFadeIn = () => {
  const [visibleItems, setVisibleItems] = createSignal<
    Set<number>
  >(new Set());
  let containerRef: HTMLDivElement | undefined;

  const items = [
    'アニメーションの基本概念',
    'SolidJSでの実装方法',
    'パフォーマンス最適化',
    'ユーザビリティの向上',
    '実践的な活用例',
  ];

  onMount(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            const index = parseInt(
              entry.target.getAttribute('data-index') || '0'
            );
            setVisibleItems(
              (prev) => new Set([...prev, index])
            );
          }
        });
      },
      { threshold: 0.3 }
    );

    // 各アイテムを監視対象に追加
    const elements =
      containerRef?.querySelectorAll('.scroll-item');
    elements?.forEach((el) => observer.observe(el));

    onCleanup(() => observer.disconnect());
  });

  return (
    <div ref={containerRef} class='scroll-container'>
      <h2>スクロールで順次表示されるコンテンツ</h2>
      {items.map((item, index) => (
        <div
          class={`scroll-item ${
            visibleItems().has(index) ? 'visible' : ''
          }`}
          data-index={index}
        >
          <div class='item-number'>{index + 1}</div>
          <div class='item-content'>
            <h3>{item}</h3>
            <p>
              この項目は、スクロール時に自動的にフェードインします。
            </p>
          </div>
        </div>
      ))}
    </div>
  );
};

スライドアニメーション

スライドアニメーションは、要素を特定の方向から滑り込ませる効果的な手法です。ナビゲーションメニューやサイドバーでよく使用されます。

サイドバースライド実装

typescriptimport { createSignal } from 'solid-js';

const SlidingSidebar = () => {
  const [isOpen, setIsOpen] = createSignal(false);
  const [selectedItem, setSelectedItem] = createSignal<
    string | null
  >(null);

  const menuItems = [
    {
      id: 'dashboard',
      label: 'ダッシュボード',
      icon: '📊',
    },
    { id: 'projects', label: 'プロジェクト', icon: '📁' },
    { id: 'analytics', label: '分析', icon: '📈' },
    { id: 'settings', label: '設定', icon: '⚙️' },
  ];

  const handleItemClick = (itemId: string) => {
    setSelectedItem(itemId);
    // モバイルでの使用を想定してメニューを閉じる
    if (window.innerWidth < 768) {
      setIsOpen(false);
    }
  };

  return (
    <div class='sidebar-layout'>
      {/* オーバーレイ */}
      <div
        class={`overlay ${isOpen() ? 'visible' : ''}`}
        onClick={() => setIsOpen(false)}
      />

      {/* サイドバー */}
      <aside class={`sidebar ${isOpen() ? 'open' : ''}`}>
        <div class='sidebar-header'>
          <h2>メニュー</h2>
          <button
            class='close-btn'
            onClick={() => setIsOpen(false)}
          >
            ✕
          </button>
        </div>

        <nav class='sidebar-nav'>
          {menuItems.map((item) => (
            <button
              class={`nav-item ${
                selectedItem() === item.id ? 'active' : ''
              }`}
              onClick={() => handleItemClick(item.id)}
            >
              <span class='nav-icon'>{item.icon}</span>
              <span class='nav-label'>{item.label}</span>
            </button>
          ))}
        </nav>
      </aside>

      {/* メインコンテンツ */}
      <main class='main-content'>
        <header class='main-header'>
          <button
            class='menu-toggle'
            onClick={() => setIsOpen(true)}
          >
            ☰
          </button>
          <h1>SolidJS アニメーションデモ</h1>
        </header>

        <div class='content-area'>
          <p>
            サイドバーは左からスライドインして表示されます。
          </p>
          <p>
            選択された項目: {selectedItem() || '未選択'}
          </p>
        </div>
      </main>
    </div>
  );
};

タブスライダーアニメーション

typescriptimport { createSignal, For } from 'solid-js';

interface TabData {
  id: string;
  title: string;
  content: string;
}

const SlidingTabs = () => {
  const [activeTab, setActiveTab] = createSignal(0);
  const [isTransitioning, setIsTransitioning] =
    createSignal(false);

  const tabs: TabData[] = [
    {
      id: 'basics',
      title: '基本概念',
      content:
        'SolidJSアニメーションの基本的な概念について学びます。リアクティブシステムとアニメーションの組み合わせは、非常に強力な表現力を提供します。',
    },
    {
      id: 'implementation',
      title: '実装方法',
      content:
        '具体的な実装手法について詳しく解説します。CSS Transitionsからカスタムフックまで、様々なアプローチを習得できます。',
    },
    {
      id: 'optimization',
      title: '最適化',
      content:
        'パフォーマンス最適化のテクニックを紹介します。60FPSを維持しながら、滑らかなアニメーションを実現する方法を学習します。',
    },
  ];

  const switchTab = (index: number) => {
    if (index !== activeTab() && !isTransitioning()) {
      setIsTransitioning(true);

      // アニメーション完了後に状態をリセット
      setTimeout(() => {
        setActiveTab(index);
        setTimeout(() => setIsTransitioning(false), 50);
      }, 150);
    }
  };

  return (
    <div class='tabs-container'>
      <div class='tab-buttons'>
        <For each={tabs}>
          {(tab, index) => (
            <button
              class={`tab-button ${
                activeTab() === index() ? 'active' : ''
              }`}
              onClick={() => switchTab(index())}
              disabled={isTransitioning()}
            >
              {tab.title}
            </button>
          )}
        </For>

        {/* アクティブタブインジケーター */}
        <div
          class='tab-indicator'
          style={{
            transform: `translateX(${activeTab() * 100}%)`,
            width: `${100 / tabs.length}%`,
          }}
        />
      </div>

      <div class='tab-content-wrapper'>
        <div
          class={`tab-content ${
            isTransitioning() ? 'transitioning' : ''
          }`}
          style={{
            transform: `translateX(-${activeTab() * 100}%)`,
          }}
        >
          <For each={tabs}>
            {(tab) => (
              <div class='tab-panel'>
                <h3>{tab.title}</h3>
                <p>{tab.content}</p>
              </div>
            )}
          </For>
        </div>
      </div>
    </div>
  );
};

回転・拡縮アニメーション

回転や拡縮アニメーションは、ユーザーの操作に対する直感的なフィードバックを提供します。ボタンのクリック効果や状態変化の表現に効果的です。

インタラクティブカード

typescriptimport { createSignal } from 'solid-js';

const InteractiveCard = () => {
  const [cards, setCards] = createSignal([
    {
      id: 1,
      title: 'CSS Transitions',
      flipped: false,
      liked: false,
    },
    {
      id: 2,
      title: 'CSS Animations',
      flipped: false,
      liked: false,
    },
    {
      id: 3,
      title: 'カスタムフック',
      flipped: false,
      liked: false,
    },
  ]);

  const flipCard = (id: number) => {
    setCards((prev) =>
      prev.map((card) =>
        card.id === id
          ? { ...card, flipped: !card.flipped }
          : card
      )
    );
  };

  const toggleLike = (id: number) => {
    setCards((prev) =>
      prev.map((card) =>
        card.id === id
          ? { ...card, liked: !card.liked }
          : card
      )
    );
  };

  return (
    <div class='cards-grid'>
      <For each={cards()}>
        {(card) => (
          <div class='card-container'>
            <div
              class={`interactive-card ${
                card.flipped ? 'flipped' : ''
              }`}
              onClick={() => flipCard(card.id)}
            >
              <div class='card-front'>
                <h3>{card.title}</h3>
                <p>クリックして裏面を見る</p>
                <div class='card-icon'>🔄</div>
              </div>

              <div class='card-back'>
                <h3>詳細情報</h3>
                <p>
                  {card.title}
                  の詳しい実装方法について解説します。
                </p>
                <button
                  class={`like-button ${
                    card.liked ? 'liked' : ''
                  }`}
                  onClick={(e) => {
                    e.stopPropagation();
                    toggleLike(card.id);
                  }}
                >
                  {card.liked ? '💖' : '🤍'}
                </button>
              </div>
            </div>
          </div>
        )}
      </For>
    </div>
  );
};

ローディングスピナー集

typescriptimport { createSignal, onMount, onCleanup } from 'solid-js';

const LoadingSpinners = () => {
  const [isLoading, setIsLoading] = createSignal(true);
  const [progress, setProgress] = createSignal(0);
  const [selectedSpinner, setSelectedSpinner] =
    createSignal('pulse');

  const spinnerTypes = [
    { id: 'pulse', name: 'パルス' },
    { id: 'rotate', name: '回転' },
    { id: 'bounce', name: 'バウンス' },
    { id: 'wave', name: 'ウェーブ' },
  ];

  // プログレス更新のシミュレーション
  onMount(() => {
    const interval = setInterval(() => {
      setProgress((prev) => {
        const newProgress = prev + 2;
        if (newProgress >= 100) {
          setIsLoading(false);
          clearInterval(interval);
          return 100;
        }
        return newProgress;
      });
    }, 100);

    onCleanup(() => clearInterval(interval));
  });

  const resetDemo = () => {
    setIsLoading(true);
    setProgress(0);
  };

  return (
    <div class='spinner-demo'>
      <div class='spinner-controls'>
        <h3>ローディングアニメーション</h3>
        <div class='spinner-selector'>
          <For each={spinnerTypes}>
            {(type) => (
              <button
                class={`spinner-option ${
                  selectedSpinner() === type.id
                    ? 'active'
                    : ''
                }`}
                onClick={() => setSelectedSpinner(type.id)}
              >
                {type.name}
              </button>
            )}
          </For>
        </div>
        <button class='reset-btn' onClick={resetDemo}>
          リセット
        </button>
      </div>

      <div class='spinner-display'>
        {isLoading() ? (
          <>
            <div
              class={`spinner spinner-${selectedSpinner()}`}
            >
              {selectedSpinner() === 'wave' && (
                <>
                  <div class='wave-bar'></div>
                  <div class='wave-bar'></div>
                  <div class='wave-bar'></div>
                  <div class='wave-bar'></div>
                </>
              )}
            </div>
            <div class='progress-info'>
              <div class='progress-bar'>
                <div
                  class='progress-fill'
                  style={{ width: `${progress()}%` }}
                />
              </div>
              <span class='progress-text'>
                {Math.round(progress())}%
              </span>
            </div>
          </>
        ) : (
          <div class='completion-message'>
            <div class='success-icon'></div>
            <p>読み込み完了!</p>
          </div>
        )}
      </div>
    </div>
  );
};

リストアニメーション

動的なリスト操作において、アイテムの追加・削除・並び替えをアニメーション付きで実行することで、ユーザーにとって分かりやすいインターフェースを提供できます。

動的 Todo リストアニメーション

typescriptimport {
  createSignal,
  For,
  createUniqueId,
} from 'solid-js';

interface TodoItem {
  id: string;
  text: string;
  completed: boolean;
  priority: 'low' | 'medium' | 'high';
  createdAt: Date;
}

const AnimatedTodoList = () => {
  const [todos, setTodos] = createSignal<TodoItem[]>([]);
  const [inputValue, setInputValue] = createSignal('');
  const [selectedPriority, setSelectedPriority] =
    createSignal<'low' | 'medium' | 'high'>('medium');
  const [filter, setFilter] = createSignal<
    'all' | 'active' | 'completed'
  >('all');

  const addTodo = () => {
    const text = inputValue().trim();
    if (text) {
      const newTodo: TodoItem = {
        id: createUniqueId(),
        text,
        completed: false,
        priority: selectedPriority(),
        createdAt: new Date(),
      };

      setTodos((prev) => [...prev, newTodo]);
      setInputValue('');
    }
  };

  const toggleTodo = (id: string) => {
    setTodos((prev) =>
      prev.map((todo) =>
        todo.id === id
          ? { ...todo, completed: !todo.completed }
          : todo
      )
    );
  };

  const removeTodo = (id: string) => {
    setTodos((prev) =>
      prev.filter((todo) => todo.id !== id)
    );
  };

  const filteredTodos = () => {
    const allTodos = todos();
    switch (filter()) {
      case 'active':
        return allTodos.filter((todo) => !todo.completed);
      case 'completed':
        return allTodos.filter((todo) => todo.completed);
      default:
        return allTodos;
    }
  };

  return (
    <div class='todo-app'>
      <div class='todo-header'>
        <h2>アニメーション付きTodoリスト</h2>

        <div class='add-todo-form'>
          <input
            type='text'
            value={inputValue()}
            onInput={(e) =>
              setInputValue(e.currentTarget.value)
            }
            onKeyPress={(e) =>
              e.key === 'Enter' && addTodo()
            }
            placeholder='新しいタスクを入力...'
            class='todo-input'
          />

          <select
            value={selectedPriority()}
            onChange={(e) =>
              setSelectedPriority(
                e.currentTarget.value as any
              )
            }
            class='priority-select'
          >
            <option value='low'></option>
            <option value='medium'></option>
            <option value='high'></option>
          </select>

          <button onClick={addTodo} class='add-btn'>
            追加
          </button>
        </div>
      </div>

      <div class='todos-container'>
        <For each={filteredTodos()}>
          {(todo) => (
            <div
              class={`todo-item priority-${todo.priority} ${
                todo.completed ? 'completed' : ''
              }`}
              data-todo-id={todo.id}
            >
              <div class='todo-content'>
                <label class='todo-checkbox'>
                  <input
                    type='checkbox'
                    checked={todo.completed}
                    onChange={() => toggleTodo(todo.id)}
                  />
                  <span class='checkmark'></span>
                </label>

                <div class='todo-text'>
                  <span
                    class={
                      todo.completed ? 'line-through' : ''
                    }
                  >
                    {todo.text}
                  </span>
                  <div class='todo-meta'>
                    <span
                      class={`priority-badge priority-${todo.priority}`}
                    >
                      {todo.priority === 'high'
                        ? '高'
                        : todo.priority === 'medium'
                        ? '中'
                        : '低'}
                    </span>
                  </div>
                </div>
              </div>

              <button
                class='remove-btn'
                onClick={() => removeTodo(todo.id)}
                title='削除'
              >
                🗑️
              </button>
            </div>
          )}
        </For>
      </div>
    </div>
  );
};

エラーハンドリングの実装例

SolidJS アニメーション実装でよく遭遇するエラーとその対処法:

typescriptimport { createSignal, onCleanup } from 'solid-js';

const ErrorHandlingExample = () => {
  const [animationError, setAnimationError] = createSignal<
    string | null
  >(null);
  const [isAnimating, setIsAnimating] = createSignal(false);
  let elementRef: HTMLDivElement | undefined;

  const problematicAnimation = async () => {
    try {
      setIsAnimating(true);
      setAnimationError(null);

      // DOM要素の存在確認
      if (!elementRef) {
        throw new Error(
          'Animation target element is not available'
        );
      }

      // Web Animations APIの対応確認
      if (!elementRef.animate) {
        throw new Error(
          'Web Animations API is not supported in this environment'
        );
      }

      // アニメーション実行
      const animation = elementRef.animate(
        [
          { transform: 'translateX(0px)', opacity: 1 },
          { transform: 'translateX(200px)', opacity: 0.5 },
          { transform: 'translateX(0px)', opacity: 1 },
        ],
        {
          duration: 1000,
          easing: 'ease-in-out',
        }
      );

      await animation.finished;
    } catch (error) {
      if (error instanceof Error) {
        setAnimationError(error.message);
        console.error('Animation error:', error);

        // フォールバック処理
        if (elementRef) {
          elementRef.style.transform = 'translateX(0px)';
          elementRef.style.opacity = '1';
        }
      }
    } finally {
      setIsAnimating(false);
    }
  };

  // メモリリーク防止
  onCleanup(() => {
    if (elementRef && isAnimating()) {
      const animations = elementRef.getAnimations?.() || [];
      animations.forEach((animation) => animation.cancel());
    }
  });

  return (
    <div class='error-handling-demo'>
      <h3>エラーハンドリング付きアニメーション</h3>

      {animationError() && (
        <div class='error-message'>
          エラー: {animationError()}
        </div>
      )}

      <div ref={elementRef} class='animation-target'>
        アニメーション対象要素
      </div>

      <button
        onClick={problematicAnimation}
        disabled={isAnimating()}
        class='test-animation-btn'
      >
        {isAnimating()
          ? 'アニメーション中...'
          : 'アニメーション実行'}
      </button>
    </div>
  );
};

まとめ

SolidJS でのアニメーション実装について、基本的な手法から応用テクニックまで幅広く解説してまいりました。この記事で学んだ内容を振り返り、実際のプロジェクトでの活用方法を整理しましょう。

習得したテクニック

本記事を通じて、以下のアニメーション実装手法を習得いただけました:

手法適用場面特徴学習コスト
CSS Transitions基本的な状態変化軽量、高性能
CSS Animations複雑なキーフレーム細かい制御が可能
Solid Transitionコンポーネント切替マウント/アンマウント対応
カスタムフック再利用性重視柔軟性が高い

これらの手法を適切に組み合わせることで、パフォーマンスを損なうことなく、魅力的なユーザーインターフェースを実現できます。

パフォーマンス最適化のポイント

アニメーション実装において、常に意識すべき最適化ポイントをまとめます:

GPU 加速の活用

  • transformopacityプロパティを積極的に使用
  • widthheightの変更は避け、scaleを使用
  • will-changeプロパティの適切な使用

メモリ管理

  • onCleanupでのアニメーション停止処理を必ず実装
  • 不要になったイベントリスナーの削除
  • Signal 更新頻度の最適化

ユーザーエクスペリエンス

  • prefers-reduced-motionメディアクエリへの対応
  • 適切なアニメーション継続時間の設定(200-500ms)
  • モバイル端末での軽量化

実践への活用

学んだテクニックを実際のプロジェクトで活用する際の指針:

段階的な実装アプローチ

  1. まずは基本的な CSS Transitions から開始
  2. 必要に応じて CSS Animations やカスタムフックを追加
  3. パフォーマンス測定と最適化を継続的に実施

チーム開発での考慮点

  • アニメーション実装のガイドラインを策定
  • 再利用可能なコンポーネントライブラリの構築
  • コードレビューでのパフォーマンス観点の確認

継続的な学習

SolidJS のアニメーション技術は日々進歩しています。継続的な学習のために以下の点を意識してください:

  • SolidJS の最新アップデートに関する情報収集
  • Web 標準の新しい API(Web Animations API 等)の活用
  • ユーザーフィードバックに基づく改善の継続

アニメーションは、技術的な実装だけでなく、ユーザーの感情に訴えかける重要な要素です。本記事で学んだテクニックを基盤として、あなた自身のクリエイティビティを発揮し、ユーザーにとって魅力的で使いやすい Web アプリケーションを作り上げてください。

SolidJS の持つ高いパフォーマンス特性を活かしながら、滑らかで直感的なアニメーションを実装することで、競合他社とは一線を画すユーザーエクスペリエンスを提供できるはずです。今後の Web 開発において、この知識が大いに役立つことを願っています。

関連リンク