レスポンシブが当たり前になった今でも、私は古いサイトの改修でときどき妙な現象に出くわします。それが、横スクロールが発生したときに 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 要素の祖先に transform・filter・perspective のいずれかが 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-timeline に scroll() を指定することで、時間ではなくスクロール位置に連動してアニメーションを進められます。横スクロールに合わせたいときは、こんな形になります。
.target {
animation: move linear;
animation-timeline: scroll(nearest inline);
}
JavaScript なしで横スクロール量に応じた表現ができるのは魅力ですが、MDN でも触れられているとおりブラウザ対応はまだ発展途上です。使う場合は @supports でのフォールバックをセットで用意しておくと安心です。単純な「固定要素を追従させたい」だけなら、無理に持ち出す必要はありません。
まとめ
fixed 要素が横スクロールに追従しないのは不具合ではなく、ビューポートを基準に固定するという仕様どおりの動きです。そのうえで、私が実際に使い分けているのはこのあたりです。
- 手軽に追従させたい → JavaScriptで scrollX ぶんだけ left をずらす
- CSSだけで済ませたい・fixed にこだわらない → position: sticky(insetの指定を忘れずに)
- 祖先コンテナ内で固定したい → 祖先に transform を効かせて包含ブロックを移す
地味な現象ですが、放っておくと操作感を確実に損ないます。まずは sticky で解決できないかを試して、それで足りなければ JavaScript、という順番で考えると迷いにくいはずです。