CSS スティッキーヘッダー追加9選 — 検索バー・CTA・通知まで
css スティッキーヘッダーとは、スクロールしてもページ上部のメニュー行が画面に貼り付いたまま残る機能で、position: stickyを一行書くだけで実現できます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 お知らせバーの折りたたみ
- 02 アイコンだけ残るナビ
- 03 タブ下線のスクロール同期
- 04 検索バーの吸収
- 05 検索窓の拡張
- 06 パンくずの表示
- 07 ユーザーメニューの圧縮
- 08 CTAボタンのスライドイン
- 09 通知バッジの出現
9個は人気順ではなく、ヘッダーの中で何が変わるかで4つに分かれます。スペースを節約するためにどこかが折りたたまれたり狭まったりするグループ(01・02・07)、スクロール位置に合わせて案内情報が動いたり現れたりするグループ(03・06)、検索UIが広がったり別の行と合体したりするグループ(04・05)、そして画面になかった要素が新しく飛び出すグループ(08・09)です。
01お知らせバーの折りたたみ
ページ最上部のプロモーションバーはスクロールを始めた瞬間、レイアウトを押し出さずに上へ消え、その空いた場所へロゴ行が上がってきます。バーとヘッダーを同じスロットに絶対配置で重ねてtransformだけを動かすので、バーが消える間もレイアウトが揺れません。
.ahc__bar {
position: absolute; inset: 0 0 auto 0;
height: 32px;
transform: translateY(0);
opacity: 1;
transition: transform .3s ease-out, opacity .3s ease-out;
}
.ahc__head {
position: absolute; left: 0; right: 0; top: 32px;
height: 56px;
transform: translateY(0);
transition: transform .3s ease-out;
}
// 実際の挙動: スクロールを始めるとバーが上へ消え、ヘッダーがその空いた場所まで上がる
.ahc.is-scrolled {
.ahc__bar { transform: translateY(-100%); opacity: 0; }
.ahc__head { transform: translateY(-32px); }
}
02アイコンだけ残るナビ
ナビゲーションリンクの文字ラベルはスクロールすると幅ごと消え、アイコンだけが残って狭くなったヘッダー幅を節約します。opacityだけを変えると余白がそのまま残ってしまうので、max-widthも一緒に0まで縮めて初めてアイコンが実際に左へ寄ります。
.ioc__label {
overflow: hidden;
white-space: nowrap;
max-width: 40px;
transform-origin: left center;
transition: max-width .3s ease-out, transform .3s ease-out, opacity .3s ease-out;
}
// 実際の挙動: スクロールするとラベルが幅ごと折りたたまれ、アイコンだけが残る
.ioc.is-scrolled .ioc__label { max-width: 0; transform: scaleX(0); opacity: 0; }
03タブ下線のスクロール同期
ヘッダー内に並んだアンカータブの下で、下線ひとつが今通過中のセクションに合わせて隣のタブへ滑っていきます。実際のページではIntersectionObserverで通過中のセクションを見つけ、そのタブの位置まで下線を移動させます。
// タブ幅がそれぞれ違うため offsetLeft・offsetWidth を実測して動かす — 当て推量のpx厳禁
var nav = document.getElementById('tusNav');
var tabs = nav.querySelectorAll('.tus__tab');
var underline = nav.querySelector('.tus__underline');
function moveTo(tab) {
var w = underline.offsetWidth;
underline.style.transform =
'translateX(' + (tab.offsetLeft + tab.offsetWidth / 2 - w / 2) + 'px)';
}
04検索バーの吸収
ヘッダー下に別行で浮いていた検索入力欄が、スクロールすると上へスライドしてメインヘッダーの1行に合流します。2つの行を固定高さのスロットに重ねておき、検索行だけをヘッダーの高さぶんtranslateYで引き上げる仕組みで、01番と同じ層重ねの構造を逆方向に使っています。
.sbm__search {
position: absolute; left: 0; right: 0; top: 56px;
height: 40px;
border-top: 1px solid rgba($stage-ink, .08);
transform: translateY(0);
transition: transform .3s ease-out;
}
// 実際の挙動: スクロールを始めると検索行が上へスライドしてヘッダー行の中に合流する
.sbm.is-scrolled .sbm__search { transform: translateY(-56px); }
05検索窓の拡張
虫眼鏡アイコンだけだった場所は、クリックすると幅が広がって実際の入力欄に変わります。9個の中で唯一スクロールと無関係な項目で、トリガーはスクロール位置ではなくクリック(フォーカス)です。
.sse__search {
width: 36px;
border-radius: $r-pill;
background: rgba($subject-cream, .12);
overflow: hidden;
transition: width .3s ease-spring, background .3s ease-spring;
&.is-open { width: 156px; background: $subject-cream; }
}
06パンくずの表示
ロゴ横の空いていた場所に、スクロールした瞬間に今見ている位置のパスがフェードとスライドで現れます。JavaScriptはクラスをひとつ付けるだけで、実際の動き(opacity・translateX)はすべてCSSのtransitionが担います。
.head__crumb {
display: flex; align-items: center; gap: $sp-2;
opacity: 0;
transform: translateX(-16px);
transition: opacity .2s ease-spring, transform .2s ease-spring;
}
// 実際の挙動: スクロールするとJSが付けたクラスがパンくずをフェード+スライドで見せる
.head.is-scrolled .head__crumb { opacity: 1; transform: translateX(0); }
07ユーザーメニューの圧縮
右のアバター横に付いていたユーザー名の文字は、スクロールすると幅ごと折りたたまれてアバターアイコンだけが残ります。02番(アイコンだけ残るナビ)と同じmax-width折りたたみ技法を右寄せ要素に適用した形で、margin-rightも一緒に0まで縮めて初めてアバターが実際に右端へ寄ります。
.head__name {
display: inline-block;
max-width: 104px;
margin-right: $sp-2;
overflow: hidden;
white-space: nowrap;
transform: scaleX(1);
transform-origin: left;
transition: max-width .3s ease-out, margin-right .3s ease-out,
opacity .3s ease-out, transform .3s ease-out;
}
.head.is-scrolled .head__name {
max-width: 0; margin-right: 0; opacity: 0; transform: scaleX(0);
}
08CTAボタンのスライドイン
本文を一定の距離以上スクロールして初めて、ヘッダー右端に購入・問い合わせボタンが画面外から滑り込んできます。9個の中でスクロールのしきい値が最大(120px)で、ページに足を踏み入れた瞬間からボタンを押し付けません。
.head__cta {
transform: translateX(140%);
transition: transform .3s ease-spring;
}
// 実際の挙動: 120pxを超えてスクロールして初めてボタンが画面外から滑り込んでくる
.head.is-scrolled .head__cta { transform: translateX(0); }
09通知バッジの出現
スクロールが80pxを超えた瞬間、ヘッダーアイコンの上に通知の点が弾けるように現れ、その後は静かに脈打ちを繰り返します。点が現れる瞬間用のキーフレームと、現れた後も回り続ける脈動用のキーフレームを分けてあるのが特徴です。
.head__dot {
transform: scale(0);
opacity: 0;
transition: transform .15s ease-pop, opacity .15s ease-pop;
}
.head.is-scrolled .head__dot {
transform: scale(1);
opacity: 1;
animation: dot-pulse-real 1.6s ease-in-out infinite;
}
@keyframes dot-pulse-real {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.3); opacity: .7; }
}
CSS スティッキーヘッダー、どこで壊れるか
一番つまずきやすいのは03番のようなスライド式インジケーターです。下線やピルのようにタブの間を滑る要素は、タブの幅がそれぞれ違うと(このデモでは「概要」61.45px・「レポート」76.17px・「設定」61.45px)translateXの値を当て推量で入れたくなりますが、均等幅でない限りその推測は必ず外れます。この項目はflex: 0 0 autoでタブ幅を内容に合わせて固定したうえで、offsetLeft・offsetWidthを実測して下線の位置を計算しています(18.73px・87.54px・156.35px)。同じ間違いをCSSだけで再現してみると、タブにflex: 1 1 0(均等分配)を与えたまま固定幅の値をそのまま移すと、下線は短いタブでは届かず長いタブでははみ出します。03番のフォルダを直接開いてタブの文字数を変えてみれば、下線が実測値に沿って再計算されるかすぐに確認できます。
次に気をつけたいのは05番(検索窓の拡張)です。名前だけ見ると「スティッキーヘッダー」の仲間に見えますが、実際はスクロールと無関係にクリックだけで開くので、スクロールイベントリスナーを期待してコードを読むと見当たらず戸惑います。04番のように2つの層を重ねたスロット構造も、順番をひとつ間違えると(絶対配置レイヤーにz-indexを与えないと)検索行がロゴの上に来てしまうので、重なり順の確認が必要です — この9個のファイルをまとめたzipの合言葉はweqydbxaで、解凍すると同じvanilla/・react/構成で直接開いて見比べられます。
アクセシビリティ — 9個がreduced-motionで残すもの
「動きを減らす」設定が有効な人に対して、この9個はそれぞれ少しずつ違う反応をします。01・02・04・06・07・08番はtransition: noneで切り替えの時間だけをなくし、実際のページでスクロールのしきい値を超えるとバーが折りたたまれ、ラベルが縮み、名前が消えるという結果自体はそのまま起こりますが瞬時に切り替わります — 機能は保ったままアニメーションだけを外した形です。09番(通知バッジ)だけは違う扱いで、切り替えだけでなくスクロール後も繰り返し続くdot-pulse-realの脈動そのものを止めるので、点が一度現れたあとは完全に静止します — 終わりなく繰り返すアニメーションは切り替えとは別にそれ自体が動きなので、個別に止める必要があります。03番(タブ下線)と05番(検索窓)はそもそもスクロールに自動で反応しないので、減らす対象の「切り替え」もユーザーがクリックした瞬間にしかなく、その切り替えの時間だけをなくします。
スクロールに反応して中身が動くヘッダーをもっと見たい方はCSSスティッキーヘッダー9選でヘッダー自体が折りたたまれたり影が付いたりする基本形を扱っています。ヘッダーだけでなくスクロール全体の演出をまとめたCSSスクロールアニメーション9選も参考になります。ほかのテーマはCSSカテゴリー一覧から探せます。position: stickyのスクロールコンテナに関する規則はMDNのリファレンスに、03番が使うビューポート検知はMDNのIntersectionObserver文書にまとまっています。
FAQ — よくある質問
9個全部スクロールしないと動きませんか?
いいえ。7個(01・02・04・06・07・08・09)はスクロール位置(20〜120pxのしきい値)に反応し、03番はどのセクションが見えているかに反応しますが、05番(検索窓の拡張)はスクロールと無関係にクリックでしか開きません。
ヘッダーが複数の層(バー+ヘッダー、ヘッダー+検索)で重なるとき、heightではなくtransformを使うのはなぜですか?
heightやmarginをアニメーションさせると、ブラウザが毎フレームレイアウトを計算し直すことになり、カクつきやすくなります。代わりに2つの層を絶対配置で重ねてtransform: translateY()だけを動かせば、レイアウト計算なしで合成レイヤーだけが変わるのでより滑らかです。01・04番がこの方式です。
vanillaとreactのコードは違いますか?
動き自体は同じです。違うのはトリガーだけで、vanillaのデモはis-demoクラスで絶え間なく繰り返し再生しますが、Reactコンポーネントは実際のscrollイベント(03番だけIntersectionObserver)を直接監視していて、条件が合ったときだけクラスを切り替えます。