Reactで、スクロールに合わせて背景をゆっくり動かす方法

​​React​CSS,Playwright,TypeScript,アクセシビリティ,スクロール駆動アニメーション

背景をゆっくり動かすには、セクションの中に背景だけの層を置き、スクロールした量に応じてその層を transform で逆向きにずらします。ずらす量は ref で要素の style を直接書き換え、React の state には入れません。背景の層は、ずれても隙間が出ないだけの量を、セクションの上下にはみ出させておきます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して、スクロール位置ごとの背景の位置を読み取りました。画面の大きさは横 480px、縦 640px です。Firefox と Safari、スマートフォンの実機(アドレスバーが出たり隠れたりして画面の高さが変わる場合を含む)では試していません。背景には CSS のグラデーションで描いた縞模様を使い、画像ファイルは試していません。

ホイール操作を3回行い、ページを200pxずつ下へスクロールする。見出しと本文の灰色の線はスクロールした量だけ上へ動き、水色の縞の背景はその半分しか動かない。上の表示はスクロール量が200px、400px、600pxと増えるのに対し、背景が動いた量は100px、200px、300pxになる
ホイールでスクロールしたときの背景の動き(右のマウスの絵、上の表示、本文に見立てた灰色の線は GIF 用に足したもの。マウスのホイールがピンクの間がスクロール中。上の数字はスクロール中とその直後だけピンク)

背景の層を作り、スクロールに合わせてずらす

セクションの中に、背景だけの div と、文字などの中身を入れる div を重ねます。セクションには overflow: hidden を付けて、背景の層がはみ出した部分を切り取ります。

css
.parallax {
  position: relative;
  height: 320px;
  overflow: hidden; /* はみ出した背景を切り取る */
}
.parallax-bg {
  position: absolute;
  inset: 0; /* 上下のはみ出しは JavaScript で決める */
  will-change: transform;
}
.parallax-content {
  position: relative; /* 背景より手前に出す */
  padding: 24px;
}

コンポーネントは次のとおりです。speed は、背景がスクロールの何倍の速さで動くかを表します。

tsx
import { useLayoutEffect, useRef } from 'react';
import type { ReactNode } from 'react';

type Props = {
  background: string; // 背景に使う CSS の background の値
  speed?: number; // 背景が動く速さ(スクロールの何倍か)。0.5 なら半分の速さ
  children: ReactNode;
};

export function ParallaxSection({ background, speed = 0.5, children }: Props) {
  const sectionRef = useRef<HTMLElement>(null);
  const bgRef = useRef<HTMLDivElement>(null);

  useLayoutEffect(() => {
    const section = sectionRef.current!;
    const bg = bgRef.current!;
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
    const lag = 1 - speed; // スクロールに対して、背景を遅らせる割合
    let frame = 0;

    // 背景がずれても隙間が出ないように、ずれる最大の量だけ上下にはみ出させる
    const resize = () => {
      const extra = reduce.matches ? 0 : (lag * (window.innerHeight + section.offsetHeight)) / 2;
      bg.style.top = `${-extra}px`;
      bg.style.bottom = `${-extra}px`;
    };

    const update = () => {
      frame = 0;
      if (reduce.matches) {
        bg.style.transform = '';
        return;
      }
      const rect = section.getBoundingClientRect();
      // セクションの中心が、画面の中心からどれだけ下にあるか
      const offset = rect.top + rect.height / 2 - window.innerHeight / 2;
      bg.style.transform = `translate3d(0, ${-offset * lag}px, 0)`;
    };

    // スクロールのたびではなく、次の描画の前に1回だけ計算する
    const onScroll = () => {
      if (frame === 0) frame = requestAnimationFrame(update);
    };
    const onResize = () => {
      resize();
      update();
    };

    onResize();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onResize);
    reduce.addEventListener('change', onResize);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onResize);
      reduce.removeEventListener('change', onResize);
    };
  }, [speed]);

  return (
    <section ref={sectionRef} className="parallax">
      <div ref={bgRef} className="parallax-bg" style={{ background }} aria-hidden="true" />
      <div className="parallax-content">{children}</div>
    </section>
  );
}

検証では、背景に次の縞模様を渡し、<ParallaxSection background={stripes}> の中に見出しを入れました。

tsx
const stripes = 'repeating-linear-gradient(180deg, #00c8e8 0 40px, #eefafd 40px 80px)';

ページを下へ d px スクロールすると、セクションは画面の上で d px 上へ動きます。このとき offset も d だけ小さくなるので、-offset * lag は d × lag だけ大きくなり、背景の層はその分だけ下へずらされます。差し引きで、背景は画面の上で d × (1 − lag)、つまり d × speed px だけ上へ動きます。speed が 0.5 なら半分の速さです。

最初の位置の計算には useLayoutEffect を使っています。ブラウザが画面を描き直す前に実行されるので、最初に描かれる時点で背景の位置を決めておけます。スクロールのたびに呼ばれる onScroll では計算せず、requestAnimationFrame で次の描画の前に1回だけ update を呼びます。リスナーに付けた passive: true は、preventDefault() を呼ばないことをブラウザに伝える指定です。MDN によると、scroll イベントではこの値を気にしなくてよいとされているので、付けなくてもかまいません。

スクロール位置ごとに、背景がどれだけずれるか

セクションはページの上から 640px の位置にあり、高さは 320px です。スクロール位置を変えながら、セクションの上端と背景の層の上端(画面の上からの位置)、transform の値を書き出しました。

text
scrollY 0: セクション上端 640 / 背景の上端 160 / transform "translate3d(0px, -240px, 0px)" / top -240px bottom -240px
scrollY 160: セクション上端 480 / 背景の上端 80 / transform "translate3d(0px, -160px, 0px)" / top -240px bottom -240px
scrollY 320: セクション上端 320 / 背景の上端 0 / transform "translate3d(0px, -80px, 0px)" / top -240px bottom -240px
scrollY 480: セクション上端 160 / 背景の上端 -80 / transform "translate3d(0px, 0px, 0px)" / top -240px bottom -240px
scrollY 640: セクション上端 0 / 背景の上端 -160 / transform "translate3d(0px, 80px, 0px)" / top -240px bottom -240px
scrollY 800: セクション上端 -160 / 背景の上端 -240 / transform "translate3d(0px, 160px, 0px)" / top -240px bottom -240px
scrollY 960: セクション上端 -320 / 背景の上端 -320 / transform "translate3d(0px, 240px, 0px)" / top -240px bottom -240px

160px スクロールするごとに、セクションの上端は 160px、背景の上端は 80px ずつ上へ動いています。scrollY が 300 から 400 へ 100px スクロールしたときも、セクションは 100px、背景は 50px 動きました。セクションの中心が画面の中心に来る scrollY 480 で、ずらす量がちょうど 0 になります。

背景を上下にはみ出させる量の決め方

背景の層をはみ出させる量は、ずらす量の最大値と同じにします。ずらす量が最大になるのは、セクションが画面の下から入り始めるときと、画面の上へ出きるときです。そのときの offset は、画面の高さとセクションの高さを足して 2 で割った値になります。これに lag を掛けたものが extra です。

検証の画面では (640 + 320) ÷ 2 × 0.5 = 240 で、上の出力の top -240px bottom -240px と、transform の最大値 240px に合っています。

はみ出させないとどうなるかも確かめました。背景の層の top と bottom を 0 に固定した場合と、はみ出しを付けた場合とで、scrollY 320(背景が上へ 80px ずらされている位置)のスクリーンショットを撮りました。セクションの下 80px にあたる1列の画素の色を数えた結果です。

text
はみ出しあり: セクションの下 80px の列で 水色 40 画素 / 白 0 画素
はみ出しなし: セクションの下 80px の列で 水色 0 画素 / 白 80 画素

はみ出しがないと、背景がずれた分だけセクションの下に何もない部分ができ、ページの白い背景が見えました。

ずらす量を state に入れると、スクロールのたびに再レンダーされる

ずらす量を useState に入れて style に渡す版も作り、比べました。ずらし方の計算は同じです。

tsx
const [y, setY] = useState(0);

useEffect(() => {
  const onScroll = () => {
    const rect = sectionRef.current!.getBoundingClientRect();
    const offset = rect.top + rect.height / 2 - window.innerHeight / 2;
    setY(-offset * 0.5);
  };
  onScroll();
  window.addEventListener('scroll', onScroll, { passive: true });
  return () => window.removeEventListener('scroll', onScroll);
}, []);

ページ全体を React の Profiler で囲み、Playwright からホイールの操作を送って 50px ずつ 20 回スクロールしたときに、レンダーが何回あったかを数えました。

text
ref で書き換える版: 表示の直後 1 回 → スクロール後 1 回(scroll イベント 20 回、scrollY 960)
state に入れる版: 表示の直後 2 回 → スクロール後 22 回(scroll イベント 20 回、scrollY 960)

state に入れる版は、scroll イベント1回ごとに1回レンダーされました。また、表示の直後にも、最初の位置を入れる setY で1回多くレンダーされています。ref で書き換える版は、最初の1回のあとは一度もレンダーされていません。こちらは state を更新せず、ref で受け取った DOM の要素の style を直接書き換えているためです。背景の位置は CSS の transform だけで決まるので、React に描き直してもらう必要はありません。

動きを減らす設定では、背景を動かさない

MDN の prefers-reduced-motion の説明によると、画面の大きな動きは、前庭障害のある人に不快感を起こすことがあります。OS で動きを減らす設定にしている人には、背景を動かさないようにします。コンポーネントでは prefers-reduced-motion を matchMedia で調べ、reduce のときは transform を空にし、はみ出しも 0 にしています。設定が途中で変わったときに計算し直すため、change イベントでも onResize を呼びます。

Playwright で reducedMotion: 'reduce' を指定して開くと、セクションの上端と背景の上端が同じ位置のまま動きませんでした。

text
js scrollY 320: セクション上端 320 / 背景の上端 320 / transform "" / translate none / top 0px
js scrollY 640: セクション上端 0 / 背景の上端 0 / transform "" / translate none / top 0px

設定を途中で切り替えたときの動きは試していません。

CSS の animation-timeline だけで書く

CSS のスクロール駆動アニメーションを使うと、JavaScript なしで同じ動きを作れます。セクションに view-timeline で名前を付け、背景の層の animation-timeline にその名前を指定します。アニメーションさせる要素は、view-timeline を付けた要素そのものでなくてもかまいません。JSX は、セクションの className を "parallax parallax-css" にし、ref と useLayoutEffect を外したものです。

tsx
<section className="parallax parallax-css">
  <div className="parallax-bg" style={{ background }} aria-hidden="true" />
  <div className="parallax-content">{children}</div>
</section>
css
.parallax-css {
  --h: 320px; /* セクションの高さと同じにする */
  view-timeline: --section block;
}
@supports (animation-timeline: view()) {
  .parallax-css .parallax-bg {
    top: calc((100vh + var(--h)) * -0.25);
    bottom: calc((100vh + var(--h)) * -0.25);
    animation: parallax linear both;
    animation-timeline: --section;
  }
}
@keyframes parallax {
  from { translate: 0 calc((100vh + var(--h)) * -0.25); }
  to { translate: 0 calc((100vh + var(--h)) * 0.25); }
}
@media (prefers-reduced-motion: reduce) {
  .parallax-css .parallax-bg { animation: none; inset: 0; }
}

この名前付きのタイムラインは、セクションが画面の下の端に見え始めたときに 0%、反対の上の端から出きったときに 100% になります。JavaScript の版の offset が最大から最小まで変わる範囲と同じなので、from と to に extra と同じ量(speed 0.5 なら、画面の高さとセクションの高さの和の 4 分の 1)を書きます。animation-timeline は animation の一括指定で auto に戻されるため、animation より後に書きます。

また、画面の高さには window.innerHeight ではなく 100vh を使っています。スマートフォンのように画面の高さが変わるブラウザでも、2つの版で背景が同じ位置になるかは、この環境では確かめていません。

JavaScript の版と同じスクロール位置で、背景の層の位置と translate を書き出しました。

text
scrollY 0: 背景の上端 160 / translate 0px -240px / top -240px bottom -240px
scrollY 160: 背景の上端 80 / translate 0px -160px / top -240px bottom -240px
scrollY 320: 背景の上端 0 / translate 0px -80px / top -240px bottom -240px
scrollY 480: 背景の上端 -80 / translate 0px 5.96856e-14px / top -240px bottom -240px
scrollY 640: 背景の上端 -160 / translate 0px 80px / top -240px bottom -240px
scrollY 800: 背景の上端 -240 / translate 0px 160px / top -240px bottom -240px
scrollY 960: 背景の上端 -320 / translate 0px 240px / top -240px bottom -240px

背景の上端は、どの位置でも JavaScript の版と同じでした(scrollY 480 の値は、計算の誤差でごくわずかに 0 からずれています)。動きを減らす設定にすると、こちらも背景は動きませんでした。

ポイント

MDN の互換性データでは、view-timeline は Chrome 115、Firefox 159、Safari 26 から使えるとされています。対応していないブラウザでは @supports の中が使われず、背景は動かないまま表示される作りです(この動きは試していません)。

JavaScript の版は offsetHeight で高さを測りますが、測るのは最初と、画面の大きさや動きを減らす設定が変わったときだけです。表示した後に中身が増えて高さが変わる場合は測り直さないので、その場合は ResizeObserver などで測り直す処理を足す必要があります(この記事では、高さ 320px のセクションでしか試していません)。

CSS の版では、セクションの高さを --h に手で書く必要があります。view-timeline に対応していないブラウザでも動かしたい場合は JavaScript の版、対応ブラウザだけでよく高さが決まっている場合は CSS の版、という選び方になります。

​Posted by 86