Reactで、スクロールして見えた要素をふわっと表示する方法

​​React​CSS,IntersectionObserver,Playwright,TypeScript,アクセシビリティ,カスタムフック

スクロールして要素が画面に入ったときにふわっと表示するには、要素を IntersectionObserver で見張り、画面に入ったらクラスを付けて、CSS の transition で opacity と translate を変えます。React の側は「画面に入ったかどうか」を state に持つだけで、動きそのものは CSS に任せます。

ここでは、そのためのカスタムフック useReveal と、包むだけで使える Reveal コンポーネントを作りました。一度表示した要素はスクロールで戻しても消さないこと、動きを減らす設定にしているユーザーには最初から表示することに加えて、ページの最後にある背の低い要素がいつまでも表示されない落とし穴も確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。画面の大きさは、数値を調べたときは 800×600、GIF は 400×560 です。Firefox と Safari、スマートフォンのブラウザは試していません。

カードが画面に入ると、透明な状態から、少し下の位置からせり上がるように表示される。横に並んだ3枚は左から順に表示され、上へ戻しても消えない
下へ2回、上へ1回スクロールする操作。表示済みのカードの数が変わった瞬間だけピンクにしている(右のホイールの絵と下の帯は GIF 用に重ねたもの)

画面に入ったら true になるフック

要素が画面に入ったかどうかは、IntersectionObserver で知ることができます。ブラウザの機能なので、useEffect の中で作り、クリーンアップで止めます。公式リファレンスにも、useEffect で IntersectionObserver を使って要素が見えているかを調べる例があります。

ts
import { useEffect, useRef, useState } from 'react';

type Options = { rootMargin?: string; threshold?: number };

// 要素が画面に入ったら true にする。一度 true になったら、見張るのをやめる
export function useReveal<T extends Element>({
  rootMargin = '0px 0px -10% 0px',
  threshold = 0,
}: Options = {}) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setVisible(true);
          observer.disconnect();
        }
      },
      { rootMargin, threshold },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [rootMargin, threshold]);

  return [ref, visible] as const;
}

rootMargin は、画面に入ったかを判定する範囲を、上・右・下・左の順に広げたり狭めたりする指定です。負の値を書くと範囲が狭まります。既定の '0px 0px -10% 0px' では、画面の下から 10% の帯を範囲から外しています。画面の下端にほんの少し顔を出しただけで表示が始まると、動きの多くが画面の下の端で進んでしまい、ユーザーの目に入りにくいと考えたためです。

画面に入ったら、disconnect() で見張るのをやめます。表示したあとは、もう判定する必要がないためです。依存配列に、オプションのオブジェクトをそのまま入れず、取り出した文字列と数値を入れているのは、レンダーのたびに新しいオブジェクトになって Effect が実行し直されるのを避けるためです(公式リファレンスの、オブジェクトへの依存を取り除く説明)。

CSS で、透明な状態から表示する

表示の動きは CSS で書きます。最初は透明で、24px 下にずらしておき、is-visible が付いたら、もとの位置へ 0.6 秒かけて戻します。

css
.reveal {
  opacity: 0;
  translate: 0 24px;
  transition: opacity 0.6s ease-out, translate 0.6s ease-out;
  transition-delay: var(--reveal-delay, 0ms);
}
.reveal.is-visible {
  opacity: 1;
  translate: 0 0;
}
@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    translate: none;
    transition: none;
  }
}

ずらす指定には、transform ではなく translate プロパティを使いました。translate は transform とは別に指定できるので、同じ要素にほかの目的で transform を指定していても、上書きし合いません。--reveal-delay は、横に並んだ要素を順に表示するために使う CSS 変数です(後の節で使います)。

フックを使うコンポーネントは、次のようになります。この記事の使い方の例(<Reveal> で包むコード)は、検証したページのコードから、検証用の id の受け渡しなどを省いたものです。

tsx
import type { CSSProperties, ReactNode } from 'react';
import { useReveal } from './useReveal';

type Props = {
  children: ReactNode;
  delay?: number; // 表示を始めるまでの遅れ(ミリ秒)
  rootMargin?: string;
  id?: string;
};

export function Reveal({ children, delay = 0, rootMargin, id }: Props) {
  const [ref, visible] = useReveal<HTMLDivElement>({ rootMargin });
  return (
    <div
      ref={ref}
      id={id}
      className={visible ? 'reveal is-visible' : 'reveal'}
      style={{ '--reveal-delay': `${delay}ms` } as CSSProperties}
    >
      {children}
    </div>
  );
}
tsx
<Reveal>
  <Card title="カード2" />
</Reveal>

スクロールしたときの動き

Playwright で、画面(ビューポート)の高さを 600px にしたブラウザでページをスクロールし、要素に is-visible が付いたかと、そのときの opacity と translate を書き出しました。カード1は、読み込んだときから画面の中にあります。

text
読み込んで 50ミリ秒後(カード1は最初から画面の中) → c1: is-visible あり / opacity 0.05 / translate 0px 22.8845px  |  c2: is-visible なし / opacity 0.00 / translate 0px 24px
さらに 700ミリ秒後 → c1: is-visible あり / opacity 1.00 / translate 0px  |  c2: is-visible なし / opacity 0.00 / translate 0px 24px

最初から画面の中にある要素も、読み込んだ直後に表示が始まりました。見張り始めたときにも、コールバックが1回呼ばれるためです(MDN の observe() の説明)。

続けて、カード2の上端を、画面の上から 95%、85% の位置へ順にスクロールし、最後にいちばん上へ戻しました。

text
カード2の上端を画面の高さの95%の位置へ → c2: is-visible なし / opacity 0.00 / translate 0px 24px
カード2の上端を85%の位置へ(100ミリ秒後) → c2: is-visible あり / opacity 0.22 / translate 0px 18.7325px
            (800ミリ秒後) → c2: is-visible あり / opacity 1.00 / translate 0px
いちばん上へ戻した → c2: is-visible あり / opacity 1.00 / translate 0px

95% の位置では、カードの上の部分が画面に見えていますが、下から 10% の帯の中なので、まだ表示されません。85% の位置まで来たところで表示が始まり、0.8 秒後には動きが終わっていました。いちばん上へ戻しても、is-visible は付いたままです。

横に並んだ要素を順に表示する

3枚のカードが横に並んでいると、同時に画面に入るので、delay を渡さなければ、3枚に同時にクラスが付き、同時に動き始めます。少しずつずらして出すには、delay に 0、100、200 ミリ秒を渡します。CSS の transition-delay に入り、動きの始まりが遅れます。

tsx
<div className="row">
  <Reveal delay={0}><Card title="横並び1" /></Reveal>
  <Reveal delay={100}><Card title="横並び2" /></Reveal>
  <Reveal delay={200}><Card title="横並び3" /></Reveal>
</div>
text
横並びの上端を85%の位置へ(合計 150ミリ秒後) → r1: is-visible あり / opacity 0.34 / translate 0px 15.8487px  |  r2: is-visible あり / opacity 0.09 / translate 0px 21.8154px  |  r3: is-visible あり / opacity 0.00 / translate 0px 24px
横並びの上端を85%の位置へ(合計 400ミリ秒後) → r1: is-visible あり / opacity 0.82 / translate 0px 4.31427px  |  r2: is-visible あり / opacity 0.65 / translate 0px 8.2968px  |  r3: is-visible あり / opacity 0.45 / translate 0px 13.141px

3枚とも同時に is-visible が付いていますが、opacity は左のカードから順に上がっています。クラスを付けるのは同時で、ずらしているのは CSS の側です。

ページの最後の要素が、いつまでも表示されない

ページのいちばん下に、高さ 40px のフッターを置き、同じ Reveal で包みました。ページの最後までスクロールしても、表示されませんでした。

text
ページの最後までスクロール → footer: is-visible なし / opacity 0.00 / translate 0px 24px
  画面の高さ 600px / 画面の下から10%を除いた範囲の下端 540px
  footer の上端 560px・下端 600px(translate を除いたもとの位置の上端 536px)

判定する範囲は画面の上から 540px までです。フッターは、それより下の 560px から 600px にありました。もとの位置なら上端は 536px で範囲に入りますが、検証環境の Chromium では、translate で 24px 下にずらしたあとの位置で判定されていました。ページの最後なので、それ以上スクロールして引き上げることもできません。

注意

ページの最後にある、背の低い要素に rootMargin で下を削った判定を使うと、要素がずっと透明のままになり、ユーザーは中身を読めません。

最後の要素だけ rootMargin を 0px にすると、表示されました。

tsx
<Reveal rootMargin="0px">
  <footer className="footer">ページの終わり</footer>
</Reveal>
text
最後の要素だけ rootMargin を 0px にして、ページの最後までスクロール → footer: is-visible あり / opacity 1.00 / translate 0px

動きを減らす設定では、最初から表示する

OS の設定で動きを減らしているユーザーには、CSS の prefers-reduced-motion が reduce になります。MDN では、大きなものが動くアニメーションは、前庭障害のある人に不快感を与えることがあると説明されています。上の CSS では、この設定のときは、透明にもせず、下にずらしもせず、transition も外しています。

Playwright の reducedMotion: 'reduce' で試したところ、スクロールする前の、画面の外にあるカードも opacity が 1 でした。

text
スクロールする前(カード2は画面の外) → c2: is-visible なし / opacity 1.00 / translate none

見えているかをそのまま state にすると、戻るたびに消える

disconnect() をせずに、isIntersecting をそのまま state に入れる書き方もあります。

tsx
const observer = new IntersectionObserver(
  ([entry]) => setVisible(entry.isIntersecting),
  { rootMargin: '0px 0px -10% 0px' },
);

この書き方で、カード2を表示してから、いちばん上へ戻し、もう一度表示しました。

text
カード2の上端を85%の位置へ → c2: is-visible あり / opacity 1.00 / translate 0px
いちばん上へ戻した → c2: is-visible なし / opacity 0.00 / translate 0px 24px
もう一度85%の位置へ(100ミリ秒後) → c2: is-visible あり / opacity 0.22 / translate 0px 18.7343px

画面から外れると透明に戻り、画面に入るたびに、また最初から表示の動きが始まりました。上下に何度も読み返すページでは、同じ動きを何度も見せることになります。一度だけ表示したい場合は、useReveal のように見張るのをやめます。

CSS の animation-timeline: view() だけで作る

CSS の animation-timeline に view() を指定すると、要素が画面にどれだけ入ったかに合わせてアニメーションが進みます。JavaScript も React の state も使いません。

css
@keyframes reveal {
  from { opacity: 0; translate: 0 24px; }
  to { opacity: 1; translate: 0 0; }
}
@supports (animation-timeline: view()) {
  .reveal-css {
    animation: reveal linear both;
    animation-timeline: view();
    animation-range: entry 0% entry 100%;
  }
}
@media (prefers-reduced-motion: reduce) {
  .reveal-css { animation: none; }
}

animation-range の entry は、要素が画面に入り始めてから、全体が入りきるまでの範囲です(MDN のタイムラインの範囲名の説明)。animation-timeline は animation の一括指定で auto に戻されるので、animation より後に書きます。

ポイント

MDN の互換性データでは、animation-timeline は Chrome 115、Firefox 159、Safari 26 から使えるとされています。この記事で試したのは Chromium 153 だけです。対応していないブラウザでは、@supports の中が使われず、アニメーションなしで最初から表示されます(この動きは試していません)。

要素の上端の位置を変えながら、カード2の opacity と translate を書き出しました。

text
カード2の上端を100%(下端にちょうど接する)の位置へ → c2: opacity 0.00 / translate 0px 24px
カード2の上端を95%の位置へ → c2: opacity 0.19 / translate 0px 19.5px
カード2の上端を86.7%(カードの半分が入る)の位置へ → c2: opacity 0.50 / translate 0px 12px
カード2の上端を73.3%(カード全体が入る)の位置へ → c2: opacity 1.00 / translate 0px
カード2の上端を50%の位置へ → c2: opacity 1.00 / translate 0px
いちばん上へ戻した → c2: opacity 0.00 / translate 0px 24px
もう一度86.7%の位置へ → c2: opacity 0.50 / translate 0px 12px
そのまま1秒待った → c2: opacity 0.50 / translate 0px 12px

こちらは時間ではなく、スクロールした量で動きが進みます。カードの半分が入ったところで opacity がちょうど 0.5 になり、その位置で1秒待っても 0.5 のままでした。上へ戻すと、また透明に戻りました。スクロールに合わせて動かしたいならこちら、一度だけ時間をかけて表示したいならフックの書き方、という使い分けになります。

​Posted by 86