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