0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[React][Hooks] Custom Hooks `useCardAnimation` の作り方

Last updated at Posted at 2025-08-03

概要

useCardAnimation は、NetflixなどのUIなどでよく見られる カードUIの動き (ホバー/選択/アニメーション) を簡単に実現できるカスタムフックです。

本記事では、

「設計の意図」を体感できるように、基本的なコンポーネントから順を追って解説します。

本記事の構成

以下の4ステップで進みます。

  1. 基礎のデモ: Cardコンポーネント(まずは手動でUI制御)
  2. useCardAnimation の hooks ファイルを作成(動作ロジックの再利用化)
  3. useCardAnimation を使ったコンポーネントを作成(導入前の練習)
  4. useCardAnimation 導入デモ(実用レベルの導入コンポーネント)

実施条件

  • ReactおよびTypeScriptの基本的な知識がある
  • Vite または Create React App 等でReact環境が構築済み
  • JSX/Props/Stateの扱いが理解できている

環境

ツール バージョン 目的
Node.js 22.5.1 Reactアプリ実行環境
React 19.1.0 UIコンポーネントの構築
TypeScript 4.9 型定義による安全な開発
fetch API ブラウザ標準搭載 API通信処理
CSS-in-JS inline style利用 アニメーションや動きの記述

基礎のデモ: Cardコンポーネント

まずは useCardAnimation を使わない状態で、手動でカードの動きを実装します。

// importセクション
import React, { useState } from 'react';

// 型定義セクション
// (今回はPropsがないため省略)

// 関数定義セクション
const SimpleCard = () => {
  // 内部状態管理セクション
  const [hovered, setHovered] = useState(false);
  const [selected, setSelected] = useState(false);

  // イベントハンドラーセクション
  const handleClick = () => setSelected((prev) => !prev);

  // JSX構築セクション
  const scale = selected ? 1.1 : hovered ? 1.05 : 1;
  const zIndex = selected ? 11 : hovered ? 2 : 1;

  return (
    <div
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
      onClick={handleClick}
      style={{
        transform: `scale(${scale})`,
        zIndex,
        transition: 'transform 300ms ease, z-index 300ms ease',
        cursor: 'pointer',
        border: '1px solid gray',
        padding: '1rem',
        margin: '1rem',
      }}
    >
      <p>{selected ? 'SelectedCard' : 'NotSelectedCard'}</p>
    </div>
  );
};

export default SimpleCard;

▶ このコードで理解すべきポイント:

  • ホバーで hovered = true
  • クリックで selected = true / false
  • scalezIndex を分岐して表現を切り替えている

useCardAnimation の hooks ファイルを作成

このセクションでは、上記のロジックを共通化し、useCardAnimation フックとして抽出します。コードの再利用性を高め、可読性を維持します。

// useCardAnimation.ts
// importセクション
import { useState, useEffect, useCallback, useRef } from 'react';

// 型定義セクション
export interface CardAnimationState {
  isHovered: boolean;
  isSelected: boolean;
  isAnimating: boolean;
}

export interface CardAnimationHandlers {
  onMouseEnter: () => void;
  onMouseLeave: () => void;
  onClick: () => void;
}

export interface UseCardAnimationReturn {
  state: CardAnimationState;
  handlers: CardAnimationHandlers;
  style: React.CSSProperties;
  className: string;
}

// カスタムフック定義セクション
export const useCardAnimation = () => {
  // 内部状態管理セクション
  const [isHovered, setIsHovered] = useState(false);
  const [isSelected, setIsSelected] = useState(false);
  const [isAnimating, setIsAnimating] = useState(false);
  const animationRef = useRef<NodeJS.Timeout | null>(null);

  // アニメーション開始
  const startAnimation = useCallback(() => {
    setIsAnimating(true);
    if (animationRef.current) clearTimeout(animationRef.current);
    animationRef.current = setTimeout(() => setIsAnimating(false), 300);
  }, []);

  // イベントハンドラーセクション
  const onMouseEnter = useCallback(() => {
    setIsHovered(true);
    startAnimation();
  }, [startAnimation]);

  const onMouseLeave = useCallback(() => {
    setIsHovered(false);
    startAnimation();
  }, [startAnimation]);

  const onClick = useCallback(() => {
    setIsSelected((prev) => !prev);
    startAnimation();
  }, [startAnimation]);

  // 副作用処理セクション
  useEffect(() => {
    return () => {
      if (animationRef.current) clearTimeout(animationRef.current);
    };
  }, []);

  // JSX構築セクション(スタイル・クラス名)
  const scale = isSelected ? 1.1 : isHovered ? 1.05 : 1;
  const zIndex = isSelected ? 11 : isHovered ? 2 : 1;

  const style: React.CSSProperties = {
    transform: `scale(${scale})`,
    zIndex,
    transition: 'transform 300ms ease, z-index 300ms ease',
    cursor: 'pointer',
    transformOrigin: 'center',
  };

  const className = [
    'animated-card',
    isHovered && 'hovered',
    isSelected && 'selected',
    isAnimating && 'animating',
  ].filter(Boolean).join(' ');

  return {
    state: { isHovered, isSelected, isAnimating },
    handlers: { onMouseEnter, onMouseLeave, onClick },
    style,
    className,
  };
};

useCardAnimation を使ったコンポーネントを作成

このステップでは、作成した useCardAnimation を活用して、実際のカードUIコンポーネントに導入してみましょう。

// CardWithAnimation.tsx
// importセクション
import React from 'react';
import { useCardAnimation } from './useCardAnimation';

// 型定義セクション
// Propsなしのため省略

// 関数定義セクション
const CardWithAnimation = () => {
  // 内部状態管理セクション
  const { state, handlers, style, className } = useCardAnimation();

  // JSX構築セクション
  return (
    <div
      {...handlers}
      style={{
        ...style,
        border: '1px solid gray',
        padding: '1rem',
        margin: '1rem',
      }}
      className={className}
    >
      <p>{state.isSelected ? 'SelectedCard' : 'NotSelectedCard'}</p>
    </div>
  );
};

export default CardWithAnimation;

useCardAnimation 導入デモ

最後に、上記の CardWithAnimation を実際に使ってみる親コンポーネントの例です。

// AnimatedCardDemo.tsx
// importセクション
import React from 'react';
import CardWithAnimation from './CardWithAnimation';

// 型定義セクション
// Propsなしのため省略

// 関数定義セクション
const AnimatedCardDemo = () => {
  // JSX構築セクション
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap' }}>
      <CardWithAnimation />
      <CardWithAnimation />
      <CardWithAnimation />
    </div>
  );
};

export default AnimatedCardDemo;

描画される結果

  • カードをホバー: 拡大(hoverScale)
  • クリック: さらに拡大(selectedScale)& z-index 切替
  • 再クリック: 状態リセット

まとめ

useCardAnimation を理解するには、まずはカード動作のロジックを手動で実装してみることが最適です。

その上で、カード表示コンポーネントの数が増えたり、動作の再利用性を高めたくなったら useCardAnimationの形で分離するのが最適です。

このような段階的理解を通じて、「カスタムフックとは何か」「どう再利用性を高めるか」 を自然に身につけることができます。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?