【CSS】position:fixedで固定している要素を横スクロールさせる方法

コードが書かれたモニター

レスポンシブが当たり前になった今でも、私は古いサイトの改修でときどき妙な現象に出くわします。それが、横スクロールが発生したときに position: fixed の要素だけがピタッと止まって取り残されるという問題です。

背景やコンテンツは横に流れていくのに、固定したメニューやバナーだけが画面にへばりついて動かない。上下スクロールでは完璧に効いていた fixed が、横方向になると急に言うことを聞かなくなる。最初に遭遇したときは正直「なんで?」と首をかしげました。

この記事では、なぜこうなるのかという理屈と、横スクロールに追従させる具体的な方法を整理しておきます。

そもそも、なぜ fixed は横スクロールに追従しないのか

position: fixed は、要素をビューポート(画面そのもの)を基準に固定する指定です。より正確に言うと、要素の配置基準(包含ブロック)が原則としてビューポートになります。MDN でも fixed は「初期包含ブロック、つまりビューポートを基準に配置される」と説明されています。

つまり fixed 要素にとって「left: 0」は常に画面の左端。ページを横にスクロールしても、要素から見れば画面の左端は動いていないので、その場に居座り続けるわけです。背景だけが流れていって置いてけぼりになるのは、仕様どおりの動きだったんですね。

ネットで調べると、こんな対策がよく出てきます。

  • overflow-x: scroll; を指定してみる
  • position: absolute; に切り替える
  • width や left をCSSで無理やり調整する

ただ、これらは fixed の「ビューポートに固定される」という性質そのものを変えるものではないので、根本的な解決にはなりません。absolute に変えれば横には動きますが、今度は上下スクロールへの追従を失います。あちらを立てればこちらが立たず、というやつです。

方法1:JavaScriptで left をスクロール量に合わせて動かす

いちばん素直なのは、横スクロール量ぶんだけ要素を左にずらす方法です。スクロールした距離と同じだけ left をマイナスに振ってやると、見た目上はコンテンツと一緒に動いているように見えます。

昔はこれを jQuery で書くのが定番でした。

$(window).on("scroll", function () {
  $(".fixed-element").css("left", -$(window).scrollLeft());
});

今なら jQuery を持ち出さなくても、素の JavaScript で十分書けます。

const el = document.querySelector(".fixed-element");

window.addEventListener("scroll", () => {
  el.style.left = `${-window.scrollX}px`;
}, { passive: true });

やっていることは同じで、window.scrollX(横スクロール量)を取得し、その値をマイナスにして left に反映しているだけです。scrollLeft ではなく window.scrollX を使うのが今どきの書き方で、addEventListener に { passive: true } を渡しておくとスクロールの処理がブロックされにくくなり、動きが滑らかになります。

シンプルですが、スクロールのたびに毎回発火するので、要素が多い場合や重い処理を挟む場合は注意が必要です。

方法2:ancestorに transform を効かせて包含ブロックをずらす

じつは JavaScript を使わず、CSSの仕様を逆手に取る手もあります。

MDN によると、fixed 要素の祖先に transformfilterperspective のいずれかが none 以外の値で設定されていると、fixed の基準がビューポートではなく、その祖先要素側に切り替わります。この仕様はしばしば「fixed が効かなくなった」というトラブルの原因として語られますが、今回はこれを味方につけられます。

.scroll-wrap {
  overflow-x: auto;
  transform: translateZ(0); /* これで包含ブロックが .scroll-wrap になる */
}

.scroll-wrap .fixed-element {
  position: fixed;
  top: 0;
  left: 0;
}

こうすると fixed 要素の基準が .scroll-wrap になるため、その中で横スクロールしたときに要素も一緒に動いてくれます。ただし挙動はブラウザやレイアウトの組み方で差が出やすく、fixed 本来のビューポート固定が失われる副作用もあるので、使いどころは見極めが必要です。個人的には、まず次の sticky で解決できないかを先に考えます。

方法3:position: sticky で素直に済ませる

用途によっては、そもそも fixed にこだわらず position: sticky を使ったほうが早いこともあります。

sticky は、要素が指定した位置に達するまでは通常フローのまま流れて、閾値を越えたところで固定される指定です。fixed と違って基準になるのは一番近いスクロール可能な祖先なので、横スクロールするコンテナの中に置けば、その中でちゃんと追従してくれます。

.sticky-element {
  position: sticky;
  left: 0;
}

ポイントは、効かせたい方向のinset(top / left など)を必ず指定すること。MDN でも、その軸のinsetが両方 auto のままだと sticky は relative と同じ挙動になってしまう、と明記されています。横方向に固定したいなら left(または right)を指定するのを忘れないようにしましょう。

なお、以前は Safari 向けに position: -webkit-sticky; を併記するのが定番でしたが、今は主要ブラウザが sticky に対応しているので、基本的には position: sticky; だけで問題ありません。

ひとつ注意点として、sticky は親要素の overflow の指定や高さに影響を受けやすく、期待どおりに動かないことがあります。効かないときは、まず親のスクロール設定とHTML構造を疑ってみてください。

おまけ:スクロール連動アニメーションという選択肢

もう少し凝った動きをさせたいなら、CSSのスクロール駆動アニメーション(scroll-driven animations)という比較的新しい仕組みもあります。

MDN によれば、animation-timelinescroll() を指定することで、時間ではなくスクロール位置に連動してアニメーションを進められます。横スクロールに合わせたいときは、こんな形になります。

.target {
  animation: move linear;
  animation-timeline: scroll(nearest inline);
}

JavaScript なしで横スクロール量に応じた表現ができるのは魅力ですが、MDN でも触れられているとおりブラウザ対応はまだ発展途上です。使う場合は @supports でのフォールバックをセットで用意しておくと安心です。単純な「固定要素を追従させたい」だけなら、無理に持ち出す必要はありません。

まとめ

fixed 要素が横スクロールに追従しないのは不具合ではなく、ビューポートを基準に固定するという仕様どおりの動きです。そのうえで、私が実際に使い分けているのはこのあたりです。

  • 手軽に追従させたい → JavaScriptで scrollX ぶんだけ left をずらす
  • CSSだけで済ませたい・fixed にこだわらない → position: sticky(insetの指定を忘れずに)
  • 祖先コンテナ内で固定したい → 祖先に transform を効かせて包含ブロックを移す

地味な現象ですが、放っておくと操作感を確実に損ないます。まずは sticky で解決できないかを試して、それで足りなければ JavaScript、という順番で考えると迷いにくいはずです。