この記事の対象: 目次や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: 親要素に 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を画面下に固定する

スマホで問い合わせボタンを画面下に張り付ける構成です。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 にすると、その要素が置かれた位置に到達するまでは出てきません。 本文末尾に置いた場合、記事を読み終わるまで表示されない。これは狙い通りのこともあれば、そうでないこともあります。
対比を整理します。
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つに収まります。
- 祖先要素の
overflow— 最頻出。コンソールスクリプトで一発で洗い出せる topの書き忘れ —position: stickyだけでは何も起きない- 親の高さ不足 — 動く余地がなければ追従しない
display/ 高さの競合 —align-self: start、float、height: 100%の連鎖
固定ヘッダーがあるサイトでは、--header-h のようなカスタムプロパティでヘッダー高さを一元管理し、top と scroll-margin-top の両方から参照する形にしておくと、後からヘッダーの高さを変えても直す箇所が1つで済みます。
追従の実装自体は、外部ライブラリを一切使わずに済みます。読み込むファイルが増えないぶん、表示速度にも効きます。JavaScript が必要になるのは「追従中だけ見た目を変える」ときと「ヘッダー高さが可変のとき」の2つだけで、どちらも十数行で書ける範囲です。
まず手元のサイトで、コンソールスクリプトを流すところから始めてください。犯人はたいてい、自分が書いた覚えのない overflow: hidden です。



