レビュー済み·難易度: 中級·更新: 2026-04-18

IntersectionObserver カスタムフックで要素の可視性を検出する

useIntersectionObserver カスタムフックを自作し、要素が viewport に入ったことを検知して遅延ロードやスクロールアニメーションをトリガーする例。

nextjsperformanceui-component

対応バージョン

nextjs 15react 19

前提環境

React の useEffect と useRef の基本を理解していること

概要

IntersectionObserveruseEffectuseRef で管理する useIntersectionObserver カスタムフックを自作し、要素が viewport に入ったことを検知する。遅延ロードとスクロールアニメーションの2パターンを示す。tanstack-query-infinite-scroll との違いは API トリガーではなく DOM 可視性の汎用フック。

インストール

# 追加インストールは不要

実装

useIntersectionObserver フック

// hooks/useIntersectionObserver.ts
import { useEffect, useRef, useState } from "react";

type Options = {
  threshold?: number;   // 交差割合(0〜1)
  rootMargin?: string;  // 検出マージン(例: "0px 0px -100px 0px")
  once?: boolean;       // 一度だけ検出したら監視解除
};

export function useIntersectionObserver<T extends HTMLElement>(
  options: Options = {}
) {
  const { threshold = 0, rootMargin = "0px", once = false } = options;
  const ref = useRef<T>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsIntersecting(true);
          if (once) observer.unobserve(el);
        } else {
          if (!once) setIsIntersecting(false);
        }
      },
      { threshold, rootMargin }
    );

    observer.observe(el);
    return () => observer.unobserve(el);
  }, [threshold, rootMargin, once]);

  return { ref, isIntersecting };
}

活用例1: スクロールアニメーション

// components/FadeInSection.tsx
"use client";

import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";
import type { ReactNode } from "react";

export function FadeInSection({ children }: { children: ReactNode }) {
  const { ref, isIntersecting } = useIntersectionObserver<HTMLDivElement>({
    threshold: 0.1,
    once: true, // 一度表示されたら再度アニメーションしない
  });

  return (
    <div
      ref={ref}
      style={{
        opacity: isIntersecting ? 1 : 0,
        transform: isIntersecting ? "translateY(0)" : "translateY(20px)",
        transition: "opacity 0.6s ease, transform 0.6s ease",
      }}
    >
      {children}
    </div>
  );
}

活用例2: 遅延画像ロード

// components/LazyImage.tsx
"use client";

import { useIntersectionObserver } from "@/hooks/useIntersectionObserver";

type Props = {
  src: string;
  alt: string;
  width: number;
  height: number;
};

export function LazyImage({ src, alt, width, height }: Props) {
  const { ref, isIntersecting } = useIntersectionObserver<HTMLDivElement>({
    rootMargin: "200px", // viewport より 200px 手前から読み込み開始
    once: true,
  });

  return (
    <div
      ref={ref}
      style={{ width, height, backgroundColor: "#f3f4f6" }}
      className="overflow-hidden rounded-lg"
    >
      {isIntersecting && (
        // eslint-disable-next-line @next/next/no-img-element
        <img
          src={src}
          alt={alt}
          width={width}
          height={height}
          className="h-full w-full object-cover"
        />
      )}
    </div>
  );
}

ページでの使用例

// app/page.tsx
import { FadeInSection } from "@/components/FadeInSection";
import { LazyImage } from "@/components/LazyImage";

const sections = [
  { id: 1, title: "セクション 1", body: "スクロールするとフェードインします。" },
  { id: 2, title: "セクション 2", body: "それぞれのセクションが独立してアニメーションします。" },
  { id: 3, title: "セクション 3", body: "once: true なので一度表示されると状態を維持します。" },
];

export default function Page() {
  return (
    <main className="mx-auto max-w-xl">
      {sections.map((section) => (
        <FadeInSection key={section.id}>
          <div className="mb-32 p-8">
            <h2 className="mb-2 text-xl font-bold">{section.title}</h2>
            <p className="text-gray-600">{section.body}</p>
          </div>
        </FadeInSection>
      ))}

      <div className="p-8">
        <h2 className="mb-4 text-xl font-bold">遅延ロード画像</h2>
        <LazyImage
          src="https://picsum.photos/400/300"
          alt="サンプル画像"
          width={400}
          height={300}
        />
      </div>
    </main>
  );
}

ポイント

  • IntersectionObserverscroll イベントより効率的。メインスレッドをブロックしない非同期 API
  • once: true にすると要素が一度 viewport に入った後に unobserve して監視を解除する。アニメーションの繰り返しを防ぐ
  • rootMargin: "200px" のように設定すると viewport に入る前から検出できる。画像の遅延ロードでは先読み用途に使う
  • threshold は交差割合(0〜1)。0 は1pxでも交差したら発火、0.5 は要素の50%が viewport に入ったら発火
  • tanstack-query-infinite-scroll との違い: 無限スクロールは API リクエストのトリガーに使う。このフックは汎用的な DOM 可視性検出(アニメーション・遅延ロード・トラッキング等)に使う

注意点

tanstack-query-infinite-scroll は API ページネーションのトリガー用途。こちらは DOM 要素の可視性検出を汎用フックとして切り出すパターン(遅延ロード・アニメーション・トラッキング等)。

関連サンプル