テクニック

position:stickyが効かない原因と直し方|スティッキー 追従 CSS position sticky 実装ガイド

この記事の対象: 目次やCTAを追従させたいが、position: sticky を書いても止まってくれない制作者
読了時間: 約14分

position: sticky は数行で書けます。書けるのに、思ったところで止まらない。スクロールしても普通に流れていってしまう。この「書いたのに効かない」が起きたとき、原因の大半は sticky を書いた要素そのものではなく、その上にいる親要素にあります。

この記事では、目次とCTAを追従させる実装を、コピーしてそのまま動く形で載せます。あわせて、効かないときに最初に潰すべき overflow の問題、固定ヘッダーがある場合のズラし方、追従中だけ見た目を変える方法までまとめました。JavaScript のスクロール監視は使いません。素のCSSで足ります。


前提と対応環境

作業を始める前に、環境を確認しておきます。

項目 内容
必要な技術 HTML / CSS のみ。JavaScript は「追従中の見た目を変える」節でのみ、任意で使用
対応ブラウザ Chrome / Edge / Firefox / Safari の現行版すべて。ベンダープレフィックスは不要
Safari の注意 -webkit-sticky は古い Safari 向けの記述。現行版では不要だが、残っていても害はない
CMS 側の前提 WordPress ならブロックテーマ・クラシックテーマどちらでも可。Shopify なら Online Store 2.0 のテーマを想定
編集権限 テーマのCSSファイルを編集できる権限。WordPress なら「外観 > テーマファイルエディター」または FTP、Shopify なら「テーマ > コードを編集」

position: sticky は主要ブラウザすべてに実装済みのプロパティです。「ブラウザが対応していないから効かない」というケースは、現在ほぼ起こりません。 効かないときは、書き方か親要素のどちらかです。

なお、後述する :has() を使ったセレクタだけは対応時期が新しく、Chrome / Safari / Firefox それぞれ比較的最近の版からになります。使う箇所で改めて触れます。導入前に、対応させたいブラウザの下限を決めてから採用してください。


position:stickyが効かない4つの原因

先に結論から書きます。効かないときは、上から順にこの4つを潰してください。ほとんどの場合、1番目か2番目で解決します。

効かないときに見る順番
1. 親のoverflowhidden/auto/scrollが1つでもあると死ぬ
2. top未指定どこで止まるか決まっていない
3. 親の高さ不足親が短いと動く余地がない
4. displayの競合flex/gridの子で潰れている

この順で見れば、原因の切り分けは数分で終わります。以下、それぞれ具体的に説明します。

原因1: 親要素に overflow が指定されている

これがもっとも多い原因です。祖先要素のどれか1つでも overflow が hidden / auto / scroll / clip になっていると、sticky はその要素の中で「効いているのに動かない」状態になります。

正確に言うと、sticky 要素は「最も近いスクロールコンテナ」を基準に止まります。親に overflow: hidden が付くと、その親がスクロールコンテナ扱いになる。しかしその親自体はスクロールしないので、結果として sticky はどこにも張り付かず、ただ流れていくように見えます。

厄介なのは、overflow: hidden は制作の現場で無意識に書かれることです。

  • 横スクロールバーを消すために body や .wrapper へ付けた
  • 回り込み解除(clearfix の代わり)で付けた
  • カルーセルやスライダーのはみ出しを隠すために付けた
  • テーマのリセットCSSに最初から入っていた

自分で書いた覚えがなくても、テーマ側が書いていることは珍しくありません。まず確認するのはここです。

確認方法:祖先の overflow を一発で洗い出す

DevTools で1つずつ親を辿るのは時間がかかります。コンソールに次のコードを貼れば、sticky 要素から html までの祖先をすべて調べて、犯人を列挙してくれます。

// DevTools のコンソールに貼り付ける
// '.js-sticky' の部分を、自分の sticky 要素のセレクタに変える
(function () {
  const target = document.querySelector('.js-sticky');
  if (!target) {
    console.warn('要素が見つかりません。セレクタを確認してください。');
    return;
  }
  const bad = [];
  let el = target.parentElement;
  while (el) {
    const s = getComputedStyle(el);
    const ov = [s.overflow, s.overflowX, s.overflowY];
    if (ov.some((v) => v !== 'visible')) {
      bad.push({
        要素: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ').join('.') : ''),
        overflow: s.overflow,
        'overflow-x': s.overflowX,
        'overflow-y': s.overflowY,
        node: el,
      });
    }
    el = el.parentElement;
  }
  if (bad.length === 0) {
    console.log('祖先に overflow の指定はありません。原因は別にあります。');
  } else {
    console.log('この要素が sticky を止めています:');
    console.table(bad);
  }
})();

変更する箇所は .js-sticky の1か所だけです。 自分が sticky を当てている要素のクラス名に書き換えてください。

console.table に出てきた要素が犯人です。複数出た場合は、全部潰す必要があります。祖先を1つ直しただけで安心せず、スクリプトをもう一度流して、リストが空になるまで繰り返してください。 1つ残っているだけで sticky は動きません。

直し方:overflow を外すか、書き換える

犯人が特定できたら、次の順で対処します。

A. そもそも不要なら消す

clearfix 目的の overflow: hidden なら、現在は不要です。親要素を display: flow-root にすれば、回り込みだけ解除して overflow は触らずに済みます。

/* 修正前:sticky が死ぬ */
.content-wrapper {
  overflow: hidden;
}

/* 修正後:回り込み解除だけを行い、sticky は生きる */
.content-wrapper {
  display: flow-root;
}

display: flow-root は主要ブラウザすべてで使えます。clearfix 用の疑似要素も不要になるので、置き換えて損はありません。

B. 横スクロール防止が目的なら overflow-x だけに絞る

これも定番の落とし穴です。overflow-x: hidden を書くと、CSS の仕様上 overflow-y は自動的に auto へ変わります。片方だけ指定したつもりが、縦方向もスクロールコンテナになってしまう。 これで sticky が死にます。

対処は、横はみ出しの原因そのものを潰すことです。

/* 修正前:横だけのつもりが、縦も auto になって sticky が死ぬ */
body {
  overflow-x: hidden;
}

/* 修正後:はみ出す要素の側で幅を制御する */
img,
video,
iframe,
table {
  max-width: 100%;
}

.full-bleed {
  /* 画面幅いっぱいの帯。100vw はスクロールバー分はみ出すので使わない */
  width: 100%;
  margin-inline: calc(50% - 50cqw);
}

横はみ出しの犯人を探すときは、次のスクリプトが早いです。ページ内で親より広い要素だけを拾います。

// はみ出している要素を探す
(function () {
  const w = document.documentElement.clientWidth;
  const out = [];
  document.querySelectorAll('*').forEach((el) => {
    const r = el.getBoundingClientRect();
    if (r.right > w + 1 || r.left < -1) {
      out.push({ 要素: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : ''), 右端: Math.round(r.right), 左端: Math.round(r.left), node: el });
    }
  });
  out.length ? console.table(out) : console.log('はみ出している要素はありません。');
})();

どうしても overflow-x: hidden を外せない場合は、その要素の外側に sticky 要素を出す構成へ変えます。sticky は overflow のある要素の中にいると死にますが、外にいれば問題ありません。

C. スクロール領域として必要なら、その中で完結させる

サイドバー内をスクロールさせたいなど、意図的な overflow: auto であれば、sticky の基準もその要素になります。この場合は sticky 側の top を、そのスクロール領域内での位置として指定し直します。

原因2: top / bottom を書いていない

position: sticky だけを書いても、何も起きません。「どこで止まるか」を top / bottom / left / right のいずれかで指定して初めて機能します。

/* 効かない */
.toc {
  position: sticky;
}

/* 効く:ビューポート上端から 24px の位置で止まる */
.toc {
  position: sticky;
  top: 24px;
}

top: 0 も有効な指定です。書かないのと 0 を書くのはまったく別なので、省略しないでください。

top と bottom を同時に書いた場合は、sticky 要素の高さがスクロール領域より小さいかぎり top が優先されます。両方書いて挙動が読めなくなるくらいなら、どちらか一方に絞るほうが安全です。

原因3: 親要素に動く余地がない

sticky 要素は、親要素の内側でしか動けません。 親の高さが sticky 要素とほぼ同じなら、追従する距離がゼロになります。「少しだけ付いてきて、すぐ止まる」ように見えるのはこのパターンです。

よくあるのは、2カラムレイアウトでサイドバーをカラム用の <div> で包み、その <div> の高さが中身ぶんしかない場合です。

/* 親のカラムを、本文の高さまで伸ばす */
.layout {
  display: grid;
  grid-template-columns: 1fr 300px;
  gap: 48px;
  /* stretch が既定なので、通常はこれで両カラムが同じ高さになる */
}

/* サイドバー側のカラムに align-self: start を付けると
   高さが中身ぶんに縮み、sticky が動けなくなる。付けないこと */

Flexbox / Grid の子要素では、既定の align-items: stretch によって親カラムが自動的に伸びます。ここに align-self: start や align-items: flex-start を書くと、カラムが縮んで sticky の動く余地が消えます。

逆に、sticky 要素そのものではなくカラム全体が伸びていることが重要なので、sticky を当てるのはカラムの中の要素にします。

親の高さが足りているかは、DevTools で数値を見なくても次の1行で判定できます。

// sticky 要素と、その親の高さを比べる
(function () {
  const el = document.querySelector('.js-sticky');
  if (!el) return console.warn('要素が見つかりません。');
  const p = el.parentElement;
  const 差 = Math.round(p.getBoundingClientRect().height - el.getBoundingClientRect().height);
  console.log(`親の高さ - 要素の高さ = ${差}px(この数値ぶんしか追従しません)`);
})();

出た数値が小さければ、追従しないのではなく「追従できる距離がそもそも無い」状態です。この場合に直すのは sticky 側ではなく、親カラムの高さです。

原因4: display や高さの競合

見落としやすいものをまとめます。

状況 起きること 対処
sticky 要素に float を併用 float が優先され sticky が無効化 float を外す
<thead> / <th> に sticky ブラウザによって挙動が異なる th 側に position: sticky を当て、border ではなく box-shadow で罫線を描く
親が display: contents ボックスが消えて基準を失う 別の要素で包む
height: 100% の連鎖 親の高さが固定され動けない min-height に変える
Flex の子で align-items: center 中央寄せのため上下に伸びず動けない stretch(既定)に戻す
祖先に contain: paint / content 新しい包含ブロックが作られ基準がズレる contain を外すか、layout だけに絞る

目次を追従させる実装

原因が分かったところで、実際に動くものを作ります。記事ページのサイドバーに目次を置き、スクロールに追従させる構成です。

HTML

<div class="l-article">
  <main class="l-article__main">
    <h1>記事タイトル</h1>
    <h2 id="section-1">最初の見出し</h2>
    <p>本文が続きます。</p>
    <h2 id="section-2">次の見出し</h2>
    <p>本文が続きます。</p>
    <h2 id="section-3">三つ目の見出し</h2>
    <p>本文が続きます。</p>
  </main>

  <aside class="l-article__side">
    <nav class="c-toc" aria-label="目次">
      <p class="c-toc__title">目次</p>
      <ul class="c-toc__list">
        <li><a href="#section-1">最初の見出し</a></li>
        <li><a href="#section-2">次の見出し</a></li>
        <li><a href="#section-3">三つ目の見出し</a></li>
      </ul>
    </nav>
  </aside>
</div>

変更する箇所:id と目次のリンク先、見出しのテキストを自分の記事に合わせます。クラス名を変える場合は、次のCSSも合わせて書き換えてください。

aria-label="目次" は残しておきます。スクリーンリーダーで、ページ内に複数ある nav を区別するために使われます。

CSS

/* ===== 2カラムレイアウト ===== */
.l-article {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 280px;
  gap: 64px;
  max-width: 1120px;
  margin-inline: auto;
  padding-inline: 24px;
  /* overflow は書かない。書くと sticky が死ぬ */
}

.l-article__main {
  /* grid の子が中身の幅で押し広げられるのを防ぐ */
  min-width: 0;
}

/* ===== 目次本体 ===== */
.c-toc {
  position: sticky;
  /* ヘッダーの高さ + 余白。固定ヘッダーが無ければ 24px などに */
  top: 24px;
  /* 目次が画面より長いときに、中だけスクロールさせる */
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  padding: 20px;
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  background: #fff;
}

.c-toc__title {
  margin: 0 0 12px;
  font-size: 14px;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.c-toc__list {
  margin: 0;
  padding: 0;
  list-style: none;
  font-size: 14px;
  line-height: 1.8;
}

.c-toc__list a {
  display: block;
  padding: 4px 0;
  color: #333;
  text-decoration: none;
  border-left: 2px solid transparent;
  padding-left: 10px;
  transition: border-color 0.2s, color 0.2s;
}

.c-toc__list a:hover,
.c-toc__list a:focus-visible {
  color: #0b6;
  border-left-color: #0b6;
}

/* ===== スマホでは追従させない ===== */
@media (max-width: 900px) {
  .l-article {
    grid-template-columns: 1fr;
    gap: 32px;
  }
  .c-toc {
    position: static;
    max-height: none;
  }
}

変更する箇所:

  • top: 24px — 固定ヘッダーがあるなら、その高さぶんを足します(次節で詳述)
  • 280px — サイドバーの幅
  • 900px — 1カラムに切り替えるブレークポイント
  • #0b6 — ホバー時のアクセントカラー
  • #e5e5e5 — 枠線の色

.c-toc の中に書いた overflow-y: auto に驚いたかもしれません。sticky を殺すのは「祖先」の overflow であって、sticky 要素自身に書くぶんには問題ありません。 目次が長くて画面に入りきらないとき、この指定があると目次の中だけがスクロールします。無いと、目次の下のほうが永久に読めなくなります。

:hover に加えて :focus-visible も同じスタイルにしているのは、キーボードで目次を辿る人のためです。目次はリンクが縦に並ぶ塊なので、今どこにいるかが見えないと一気に使えなくなります。

スマホで position: static に戻しているのは意図的です。狭い画面で目次が張り付くと、本文の表示領域を圧迫します。追従させるならこの後の「CTAを画面下に固定する」の形にしたほうが実用的です。

現在地をハイライトする(任意)

目次が長い記事では、「今どこを読んでいるか」が分かるだけで体感が変わります。これも IntersectionObserver で書けます。

<script>
  (function () {
    const links = document.querySelectorAll('.c-toc__list a');
    if (!links.length) return;
    const map = new Map();
    links.forEach((a) => {
      const id = a.getAttribute('href').slice(1);
      const sec = document.getElementById(id);
      if (sec) map.set(sec, a);
    });

    const io = new IntersectionObserver(
      (entries) => {
        entries.forEach((e) => {
          if (e.isIntersecting) {
            links.forEach((a) => a.classList.remove('is-current'));
            map.get(e.target)?.classList.add('is-current');
          }
        });
      },
      // 画面上部 20% の帯に入った見出しを「現在地」とみなす
      { rootMargin: '0px 0px -80% 0px', threshold: 0 }
    );

    map.forEach((_, sec) => io.observe(sec));
  })();
</script>
.c-toc__list a.is-current {
  color: #0b6;
  border-left-color: #0b6;
  font-weight: 700;
}

変更する箇所:-80% の値。小さくすると判定ラインが画面下寄りになります。

これは無くても記事は成立するので、実装コストを掛けたくないなら省いて構いません。

Shopify のテーマでこの構成を組む場合、記事テンプレートのセクションに配置します。セクション化の考え方は次の記事が近い形を扱っています。


固定ヘッダーの高さぶんズラす

固定ヘッダーがあるサイトで top: 0 を書くと、追従した要素がヘッダーの裏側に潜り込みます。ヘッダーの高さぶんだけ下げる必要がありますが、数値をベタ書きするとヘッダーの高さを変えたときに直し忘れます。

カスタムプロパティで一元管理する

ヘッダーの高さを変数にして、ヘッダー自身と sticky 要素の両方から参照します。これで修正箇所が1つになります。

:root {
  /* ヘッダーの高さ。ここだけ変えれば全部追従する */
  --header-h: 64px;
  /* 追従要素とヘッダーの間に空ける余白 */
  --sticky-gap: 16px;
}

@media (max-width: 900px) {
  :root {
    /* スマホでヘッダーが低くなるなら、ここで上書き */
    --header-h: 56px;
  }
}

/* ヘッダー本体も同じ変数を見る */
.l-header {
  position: fixed;
  inset: 0 0 auto;
  height: var(--header-h);
  z-index: 100;
  background: #fff;
}

/* ヘッダーが fixed なので、本文の先頭をその高さぶん下げる */
.l-body {
  padding-top: var(--header-h);
}

/* 追従要素は「ヘッダー高 + 余白」の位置で止まる */
.c-toc {
  position: sticky;
  top: calc(var(--header-h) + var(--sticky-gap));
}

変更する箇所:--header-h の 64px を実際のヘッダー高さに、--sticky-gap を好みの余白に。

アンカーリンクの飛び先も同じ変数で直す

固定ヘッダーがあると、目次のリンクを押したときに見出しがヘッダーの裏に隠れます。これは scroll-margin-top で直します。JavaScript は不要です。

/* 見出しの飛び先を、ヘッダーの高さぶん手前で止める */
h2[id],
h3[id],
[id] {
  scroll-margin-top: calc(var(--header-h) + 24px);
}

/* 目次のリンクを押したときに滑らかに動かす(任意) */
html {
  scroll-behavior: smooth;
}

/* 動きを減らす設定をしている人には効かせない */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

scroll-margin-top は主要ブラウザすべてで使えます。かつて使われていた「透明な擬似要素を置いてマイナスマージンで引く」というハックはもう不要です。

prefers-reduced-motion の分岐は忘れずに入れてください。スムーススクロールは、前庭障害のある人にとって不快や体調不良の原因になります。OS 側で「視差効果を減らす」を有効にしている人には、瞬時に飛ぶ挙動に戻すのが正しい対応です。

ヘッダーの高さが可変のとき

ヘッダーの高さがコンテンツによって変わる場合は、JavaScript で実測して変数に書き込みます。これだけは CSS で完結しません。

<!-- </body> の直前に置く -->
<script>
  (function () {
    const header = document.querySelector('.l-header');
    if (!header) return;

    const apply = () => {
      const h = header.getBoundingClientRect().height;
      document.documentElement.style.setProperty('--header-h', h + 'px');
    };

    apply();
    // ヘッダーの高さが変わったら追従する
    if ('ResizeObserver' in window) {
      new ResizeObserver(apply).observe(header);
    } else {
      window.addEventListener('resize', apply);
    }
  })();
</script>

変更する箇所:.l-header を自分のヘッダーのセレクタに。

ResizeObserver は主要ブラウザで使えますが、古い環境向けに resize イベントへのフォールバックを入れてあります。

なお、この方法だと初回描画の直後に --header-h が書き換わるため、一瞬だけレイアウトがズレることがあります。気になる場合は、CSS 側の --header-h にあらかじめ実測に近い初期値を入れておいてください。JavaScript が無効な環境でも、その初期値で成立します。


CTAを画面下に固定する

CTAを画面下に固定する

スマホで問い合わせボタンを画面下に張り付ける構成です。position: fixed でも作れますが、sticky を使うと「特定の位置を過ぎてから出す」が CSS だけで書けます。

常に下に出す形(もっとも簡単)

<!-- 本文の末尾、フッターの手前に置く -->
<div class="c-cta-bar">
  <a class="c-cta-bar__btn" href="/contact/">無料で相談する</a>
</div>
.c-cta-bar {
  position: sticky;
  bottom: 0;
  z-index: 50;
  padding: 12px 16px;
  /* iPhone のホームバーに被らないようにする */
  padding-bottom: calc(12px + env(safe-area-inset-bottom));
  background: rgba(255, 255, 255, 0.94);
  backdrop-filter: blur(8px);
  border-top: 1px solid #e5e5e5;
}

.c-cta-bar__btn {
  display: block;
  padding: 14px;
  text-align: center;
  font-weight: 700;
  color: #fff;
  background: #0b6;
  border-radius: 8px;
  text-decoration: none;
}

/* PC では出さない場合 */
@media (min-width: 901px) {
  .c-cta-bar {
    display: none;
  }
}

変更する箇所:/contact/ のリンク先、ボタンの文言、#0b6 の色、PC で出すかどうか。

env(safe-area-inset-bottom) は、iPhone のホームインジケーターにボタンが重ならないようにする指定です。これが無いと、画面下端のボタンが押しにくくなります。スマホ実機で必ず確認してください。

ボタンの高さは、指で押す領域として最低 44px は確保してください。padding: 14px と本文サイズの組み合わせで、おおむねその水準になります。文字を小さくしてバーを薄くすると、見た目は整っても押しにくくなります。

スクロールしても隠れないバーの弱点

position: sticky + bottom: 0 にすると、その要素が置かれた位置に到達するまでは出てきません。 本文末尾に置いた場合、記事を読み終わるまで表示されない。これは狙い通りのこともあれば、そうでないこともあります。

対比を整理します。

fixed と sticky の使い分け

sticky + bottom:0

  • 置いた位置に来てから出る
  • フッターに重ならず自然に流れる
  • 本文を読ませてから出したい時に向く

fixed + bottom:0

  • 最初から常に出ている
  • フッターに重なるので調整が要る
  • 本文の下端に余白を足す必要がある

読み終わった人にだけ出したいなら sticky、最初から見せたいなら fixed です。記事系のコンテンツなら sticky のほうが押し付けがましさが減ります。

判断の軸は、そのページで先に渡すものが情報か行動かです。読んで判断してもらう記事なら sticky、価格や申込条件が上部で完結しているLPなら fixed が噛み合います。両方を同じサイトで混在させるなら、テンプレート単位で使い分けを決めておくと、後から触る人が迷いません。

fixed を使うなら本文に余白を足す

fixed を選ぶ場合、バーの高さぶんだけ本文の下に余白が必要です。忘れるとフッターの最終行がバーの裏に隠れます。

:root {
  --cta-bar-h: 72px;
}

.c-cta-bar--fixed {
  position: fixed;
  inset: auto 0 0;
  height: var(--cta-bar-h);
  z-index: 50;
}

/* バーの高さぶん、ページ全体の下に余白を確保する */
body {
  padding-bottom: calc(var(--cta-bar-h) + env(safe-area-inset-bottom));
}

@media (min-width: 901px) {
  body {
    padding-bottom: 0;
  }
}

追従中だけ見た目を変える

「張り付いた瞬間に影を付けたい」という要望はよく来ます。CSS だけでは「今 sticky が効いているか」を判定できないため、ここだけ JavaScript を使います。ライブラリは不要で、IntersectionObserver で十数行です。

仕組み

sticky 要素の1px 手前に、目印になる空の要素(センチネル)を置きます。センチネルが画面外に出た=sticky が張り付いた、と判定します。スクロールイベントを毎フレーム監視しないので、動作が軽いのが利点です。

<!-- sticky 要素の直前に、高さ 1px の目印を置く -->
<div class="js-sticky-sentinel" aria-hidden="true"></div>
<header class="c-subnav js-sticky">
  <a href="#section-1">最初の見出し</a>
  <a href="#section-2">次の見出し</a>
</header>
.js-sticky-sentinel {
  height: 1px;
}

.c-subnav {
  position: sticky;
  top: var(--header-h, 0px);
  z-index: 40;
  display: flex;
  gap: 20px;
  padding: 12px 16px;
  background: #fff;
  transition: box-shadow 0.2s, background-color 0.2s;
}

/* 張り付いている間だけ影を出す */
.c-subnav.is-stuck {
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
  background: rgba(255, 255, 255, 0.96);
  backdrop-filter: blur(8px);
}
<script>
  (function () {
    const sentinel = document.querySelector('.js-sticky-sentinel');
    const bar = document.querySelector('.js-sticky');
    if (!sentinel || !bar) return;

    const headerH = parseInt(
      getComputedStyle(document.documentElement).getPropertyValue('--header-h') || '0',
      10
    );

    const io = new IntersectionObserver(
      ([entry]) => {
        // センチネルが見えなくなった = sticky が張り付いた
        bar.classList.toggle('is-stuck', !entry.isIntersecting);
      },
      // ヘッダーの高さぶん、判定ラインを下げる
      { rootMargin: `-${headerH}px 0px 0px 0px`, threshold: 0 }
    );

    io.observe(sentinel);
  })();
</script>

変更する箇所:.js-sticky-sentinel と .js-sticky のクラス名、is-stuck 時のスタイル。

aria-hidden="true" をセンチネルに付けているのは、意味を持たない要素をスクリーンリーダーに読ませないためです。

--header-h を JavaScript 側で読んでいるので、前節の「ヘッダー高さを実測して書き込む」処理を入れている場合は、そちらを先に実行してください。 読む順が逆だと、判定ラインが初期値のままズレます。

CSSだけで済ませる方法もある

影が不要で「張り付いたら背景色を濃くしたい」程度なら、:has() を使って親側から判定する手もあります。ただし条件が限られるうえ、:has() は比較的新しい機能なので、対応させたいブラウザの下限を確認してから使ってください。判定の確実さでは、上の IntersectionObserver のほうが安定します。


Shopify / WordPress で入れる場所

テーマごとに、どのファイルのどこへ書くかをまとめます。

Shopify(Online Store 2.0)

やること ファイル
CSS を追加 assets/ に custom-sticky.css を新規作成
CSS を読み込む layout/theme.liquid の </head> 直前に1行追加
HTML を置く 対象セクション(例:sections/main-article.liquid)
{%- comment -%} layout/theme.liquid の </head> 直前に追加 {%- endcomment -%}
{{ 'custom-sticky.css' | asset_url | stylesheet_tag }}

Dawn 系のテーマでは、.shopify-section に overflow が付いていることがあります。効かないときは前述のコンソールスクリプトを流してください。

もう一点、Shopify のセクションはテーマエディタで並べ替えられる前提です。sticky を当てたセクションが、想定と違う親の中に入る可能性があります。実装したら、実際にエディタで並べ替えてから確認してください。

セクションの作り方そのものは、こちらの記事で扱っている手順と同じ考え方です。

WordPress(ブロックテーマ)

やること ファイル
CSS を追加 子テーマの style.css、または「外観 > カスタマイズ > 追加CSS」
HTML を置く ブロックパターン、またはテンプレートパーツ

ブロックテーマでは、wp-site-blocks や is-layout-constrained の付いた要素に overflow が入っていることがあります。テーマ更新で上書きされないよう、必ず子テーマか「追加CSS」に書いてください。

編集者が触っても崩れない形で入れたい場合、パターン化しておくと安全です。

クラシックテーマ

functions.php から wp_enqueue_style() で読み込みます。テーマ本体の style.css を直接編集すると、更新時に消えます。

<?php
// 子テーマの functions.php に追加
add_action( 'wp_enqueue_scripts', function () {
    wp_enqueue_style(
        'custom-sticky',
        get_stylesheet_directory_uri() . '/css/custom-sticky.css',
        array(),
        filemtime( get_stylesheet_directory() . '/css/custom-sticky.css' )
    );
} );

filemtime() をバージョン番号に使うと、ファイルを更新したときに自動でキャッシュが切り替わります。手でバージョン番号を上げる必要がなくなります。


うまくいかないとき

ここまでやっても動かない場合のチェックリストです。上から順に潰してください。

1. コンソールスクリプトを流したか

「原因1」のスクリプトを実行して、祖先の overflow を確認する。実務上、ここで大半が解決します。

2. top を書いたか

position: sticky だけでは動きません。top: 0 でも書く。

3. 親の高さは足りているか

DevTools で親要素を選び、Layout パネルで高さを確認します。sticky 要素と親の高さが同じなら、動く余地がありません。「原因3」の高さ比較スクリプトでも判定できます。

4. transform / filter / will-change が祖先に付いていないか

これらは新しい包含ブロックを作ります。sticky そのものを止めるわけではありませんが、position: fixed を併用している箇所で予想外の挙動になります。アニメーション用に will-change: transform を親に付けていないか確認してください。

5. スクロールしているのは body か、別の要素か

html, body { height: 100%; overflow: auto; } のような指定があると、スクロールコンテナが body になります。この場合、sticky の基準もビューポートではなく body になり、想定と違う位置で止まります。可能なら height: 100% を外し、min-height: 100dvh に置き換えます。

6. 表示は正しいのに、印刷やPDFで崩れる

position: sticky は印刷時の挙動がブラウザによって異なります。印刷用のCSSで static に戻してください。

@media print {
  .c-toc,
  .c-cta-bar,
  .c-subnav {
    position: static;
    max-height: none;
    overflow: visible;
  }
}

7. iOS でカクつく

sticky 要素に backdrop-filter と大きな box-shadow を同時に掛けると、端末によっては描画が重くなります。片方を外すか、影を 0 1px 3px 程度に抑えてください。

8. 目次の下部が読めない

目次が画面高さより長いパターンです。max-height: calc(100vh - 余白) と overflow-y: auto をセットで入れます。この overflow は sticky 要素自身に書くので問題ありません。

9. iOS Safari で 100vh が合わない

アドレスバーの伸縮で 100vh の実寸がズレます。100dvh に置き換えると、表示領域に合わせて追従します。dvh は主要ブラウザの現行版で使えますが、古い端末を切りたくない場合は 100vh を先に書いて上書きする形にします。

.c-toc {
  max-height: calc(100vh - 48px);
  max-height: calc(100dvh - 48px);
}

10. 追従はするが、ちらつく

sticky 要素と隣接要素の両方に transition を掛けていると、張り付いた瞬間に再描画が重なります。transition の対象を box-shadow と background-color に絞ってください。all は使わないでください。

11. 別の追従要素と重なる

固定ヘッダー・追従サブナビ・下部CTAバーが同居すると、z-index の取り合いになります。追従するものだけ、値を先に決めて一覧にしておくのが確実です。

:root {
  --z-header: 100;
  --z-subnav: 40;
  --z-cta: 50;
}

12. モーダルを開くと追従要素が上に出てくる

モーダルやドロワーの z-index が、追従要素より低いパターンです。<dialog> 要素と showModal() を使うと、トップレイヤーに表示されるので z-index の競合そのものが起きません。既存のモーダルを置き換えられるなら、そのほうが楽です。


よくある質問

position: sticky と position: fixed はどう使い分けますか

親要素の範囲内でだけ追従させたいなら sticky、画面のどこにいても常に固定したいなら fixed です。sticky は親を出ると自然に流れていくので、フッターとの重なりを気にせず済みます。CTAバーのように「最初から常に見せたい」ものは fixed が向きます。

親の overflow: hidden をどうしても外せない場合はどうしますか

その要素の外側に sticky 要素を出す構成へ変えるのが確実です。HTMLの構造を変えられない場合は、overflow: hidden を display: flow-root(回り込み解除が目的のとき)や、はみ出す子要素側の max-width: 100%(横スクロール防止が目的のとき)に置き換えられないか検討してください。目的別に代替手段があります。

スマホでも目次を追従させるべきですか

サイドバー形式のまま追従させるのは避けたほうが無難です。狭い画面では本文の表示領域を圧迫します。スマホでは目次を折りたたみ(details / summary)にして本文の冒頭に置くか、画面下に固定するCTAバー形式に切り替えるほうが実用的です。

JavaScript を使わずに「追従中だけ影を付ける」ことはできますか

:has() を使えば近いことはできますが、条件が限られ、対応ブラウザも比較的新しい版に限られます。確実に動かすなら、記事内で紹介した IntersectionObserver を使う方法をおすすめします。十数行で済み、スクロールイベントを監視しないので動作も軽いです。

テーブルのヘッダー行を固定したいときも同じ方法でいけますか

考え方は同じですが、<thead> ではなく <th> に position: sticky; top: 0; を当ててください。あわせて、sticky 中は border が正しく描画されないブラウザがあるため、罫線は box-shadow: inset 0 -1px 0 #ddd; で描くと安定します。テーブルを包む要素に overflow-x: auto を付ける場合、そのスクロールコンテナが基準になる点も押さえておいてください。

追従する要素を増やすと表示速度に影響しますか

CSS だけで組んでいる範囲なら、読み込むファイルが増えないので影響はほぼありません。重くなるのは、スクロールイベントで位置を毎回計算するJavaScript実装に切り替えたときと、backdrop-filter のような描画コストの高い指定を追従要素に重ねたときです。この記事の構成は前者を避け、後者も1要素に限定しています。


まとめ

position: sticky が効かないときの原因は、ほぼ次の4つに収まります。

  1. 祖先要素の overflow — 最頻出。コンソールスクリプトで一発で洗い出せる
  2. top の書き忘れ — position: sticky だけでは何も起きない
  3. 親の高さ不足 — 動く余地がなければ追従しない
  4. display / 高さの競合 — align-self: start、float、height: 100% の連鎖

固定ヘッダーがあるサイトでは、--header-h のようなカスタムプロパティでヘッダー高さを一元管理し、top と scroll-margin-top の両方から参照する形にしておくと、後からヘッダーの高さを変えても直す箇所が1つで済みます。

追従の実装自体は、外部ライブラリを一切使わずに済みます。読み込むファイルが増えないぶん、表示速度にも効きます。JavaScript が必要になるのは「追従中だけ見た目を変える」ときと「ヘッダー高さが可変のとき」の2つだけで、どちらも十数行で書ける範囲です。

まず手元のサイトで、コンソールスクリプトを流すところから始めてください。犯人はたいてい、自分が書いた覚えのない overflow: hidden です。