URLのアンカー(ハッシュ)で処理を分ける — その多くは、もうJavaScriptが要らない

先に結論

「URLの # で処理を分ける」という作業の大半は、いまCSSだけで書けます。 JavaScriptを書く前に、下の表を見てください。

表1:やりたいことと、いま必要なもの
やりたいこと昔いま
ページ内リンクをなめらかに動かすJSでアニメーションscroll-behavior: smooth(CSS)
固定ヘッダーで見出しが隠れるJSでオフセット計算scroll-margin-top(CSS1行)
ハッシュに応じて表示を切り替えるJSで display を操作:target(CSS)
ハッシュの値を読んで処理を分けるlocation.hashlocation.hash(いまも同じ)
ハッシュの変化を検知するhashchangehashchange(いまも同じ)

手元のChrome 152で、CSSの3つが使えることを確認しました。

表2:対応状況(Chrome 152で実測)
機能CSS.supports()
scroll-behavior: smoothtrue
scroll-margin-toptrue
:targettrue

そして、JSを書く場合の落とし穴を先に書きます。 日本語のIDをアンカーにすると、location.hash はパーセントエンコードされた文字列を返します。 そのまま getElementById() に渡しても見つかりません(実測)。

JSが要らなくなったもの

スムーススクロール

ページ内リンクをなめらかに動かすために、昔はJSでアニメーションを書いていました。 いまはCSS1行です。

html {
  scroll-behavior: smooth;
}

これだけで、<a href="#section"> のリンクも、 location.hash への代入も、なめらかに動きます。

利用者が「視差効果を減らす」設定を入れている場合は、止めるのが作法です。 これもCSSで書けます。

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

固定ヘッダーで見出しが隠れる

これが一番よく踏む問題です。 画面上部に固定ヘッダーがあると、アンカーで飛んだ見出しがヘッダーの裏に隠れます。 昔はJSでスクロール位置を計算し直していました。

MDNは scroll-margin-top の用途として、まさにこれを挙げています。

「画面上に固定されたヘッダーなどの要素がある場合、このプロパティを使用することで、 スクロール位置を調整し、内容がヘッダーに隠れることを防ぎます」
— scroll-margin-top(MDN)

高さ60pxの固定ヘッダーを置いたページで、実際に測りました。

表3:アンカーで飛んだあとの見出しの位置(Chrome 152で実測)
指定見出しの上端見え方
なし0pxヘッダー(60px)の裏に隠れる
scroll-margin-top: 80px80pxヘッダーの下に出る

書き方は1行です。飛び先の要素に付けます。

/* 飛び先になる見出しに付ける(リンク側ではない) */
h2, h3, [id] {
  scroll-margin-top: 80px;   /* ヘッダーの高さ + 余白 */
}

ヘッダーの高さが可変なら、カスタムプロパティにしておくと楽です。 JSでの位置計算と違って、画面幅が変わっても勝手に追従します。

表示の出し分け

「#tab-a ならAを出す、#tab-b ならBを出す」という切り替えも、 :target でCSSだけで書けます。

/* 既定では隠しておく */
#panel-a, #panel-b { display: none; }

/* URLのハッシュが自分を指しているときだけ出す */
#panel-a:target, #panel-b:target { display: block; }
<a href="#panel-a">Aを見る</a>
<a href="#panel-b">Bを見る</a>

<div id="panel-a">パネルA</div>
<div id="panel-b">パネルB</div>
表4::target の向き不向き
場面向くか理由
URLで状態を共有したい向くURLがそのまま状態になる
JSを使いたくない向くスクリプトが落ちても動く
戻るボタンで前の状態に戻したい向く履歴に残る
初期表示で何かを出しておきたい向かないハッシュが無い状態では、どれも選ばれない
複数を同時に開きたい向かない:target は1つだけ
アコーディオン(開閉)向かない<details> のほうが素直

4行目が :target の弱点です。 ハッシュが無い状態では何も選ばれないので、「最初はAを出しておく」が書けません。 既定で出したいものがあるなら、JSか <details> を検討してください。

それでもJSが要る場合

location.hash の値の形

location.hash は # を含みます。 ここを勘違いすると、比較がすべて外れます。実測した値です。

表5:location.hash が返す値(Chrome 152で実測)
URLの末尾location.hash文字数
(ハッシュ無し)""(空文字)0
#plain"#plain"6

無いときは null ではなく空文字です。 # を外したいなら .slice(1) を使います。

// 「#」込みで比較する
if (location.hash === '#detail') { ... }

// 「#」を外して使う
const id = location.hash.slice(1);   // '' か 'detail'
if (!id) return;                     // 無いときは空文字

日本語アンカーで getElementById が失敗する

ここが実務で一番詰まります。 見出しのIDを日本語にしている場合、location.hash はエンコードされた文字列を返します。

IDが 日本語見出し(7文字)の要素に飛んだとき、実際に返ってきた値です。

表6:日本語アンカーの値(Chrome 152で実測)
見るもの値
HTMLの id 属性日本語見出し
location.hash#%E6%97%A5%E6%9C%AC%E8%AA%9E%E8%A6%8B%E5%87%BA%E3%81%97
その文字数55(元は7文字)
decodeURIComponent() した結果#日本語見出し

そのまま要素を探すと、見つかりません。

表7:要素が引けるかどうか(実測)
書き方結果
document.getElementById( location.hash.slice(1) )false(見つからない)
document.getElementById( decodeURIComponent(location.hash.slice(1)) )true(見つかる)

英数字のIDでテストしていると、絶対に気づけません。 日本語の見出しを使っているサイトで、特定の見出しだけスクロールしないという症状が出たら、これです。 decodeURIComponent() を通してください。

// 日本語のIDでも引ける書き方
const raw = location.hash.slice(1);
if (!raw) return;

const id = decodeURIComponent(raw);
const el = document.getElementById(id);
if (el) el.scrollIntoView();

なお、document.getElementById('日本語見出し') のように 日本語を直接渡す分には問題なく引けます(実測で確認)。 問題は location.hash から取った値を使うときだけです。

変化を検知する

ハッシュが変わったことを知りたいなら hashchange です。 ページの読み込み時には発火しないので、初回は自分で呼びます。

function onHash() {
  const id = decodeURIComponent(location.hash.slice(1));
  // ここで処理を分ける
}

addEventListener('hashchange', onHash);
onHash();   // 初回は自分で1回呼ぶ

ハッシュでの画面切り替えは、アクセス解析に乗りません。 GA4が自動で検知するのは pushState・popState・replaceState の3つで、 hashchange は入っていません。 詳しくは GA4でSPAのページビューが計測されない5つの原因 に書きました。

ハッシュを消す

処理が終わったあと、URLから # を消したいことがあります。 location.hash = '' だと、# だけが残ります。

表8:ハッシュの消し方
書き方結果履歴
location.hash = ''# が残る増える
history.replaceState(null, '', location.pathname + location.search)きれいに消える増えない
history.pushState(...)消える増える(戻るで復活)

2行目を使ってください。 replaceState なら履歴を増やさないので、戻るボタンが壊れません。

踏みやすい罠

表9:よくある詰まり方
症状原因
日本語の見出しだけ飛ばないdecodeURIComponent() を通していない(表7)
比較が常に falselocation.hash には # が含まれる(表5)
読み込み時に処理が走らないhashchange は初回発火しない(詳しく)
見出しがヘッダーに隠れるscroll-margin-top を付けていない(表3)
戻るボタンが効かなくなるハッシュを消すのに pushState を使っている(表8)
同じページ内なのに再読み込みされる<a href="#"> のまま preventDefault() していない
別ページから飛ぶと位置がずれる画像の読み込みで高さが変わっている。画像に width/height を書く

最後の行は見落とされます。 アンカー付きのURLで直接開くと、スクロール位置を決めたあとに画像が読み込まれて高さが変わります。 画像に寸法を書いておけば、読み込み前から場所が確保されるので、ずれません。

AIに任せるときの注意

表10:AIへの頼み方
頼むこと向くか理由
「このJSのスムーススクロールをCSSに置き換えて」向く機械的な置き換え。行数が大きく減る
「:target でタブを作って」向く定型。表4の制約だけ自分で確認する
「日本語IDで飛ばない。原因は?」(コードを貼る)向くエンコードの話は読めば分かる
「スムーススクロールを実装して」向かないJSのアニメーションが返る。いまはCSS1行
「固定ヘッダーの分だけずらして」向かないJSでのオフセット計算が返る。scroll-margin-top で済む
日本語アンカーの動作確認向かない英数字でテストすると再現しない。自分で日本語で試す

判定の目安です。出てきたコードが scrollTop のアニメーションや offsetTop の計算をしていたら、古い書き方です。 CSSで足りないか先に確認してください。

よくある質問

スムーススクロールにJSは要りますか

要りません。html { scroll-behavior: smooth; } の1行です(詳しく)。 Chrome 152で対応を確認しました。

固定ヘッダーで見出しが隠れます

飛び先の要素に scroll-margin-top を付けてください(表3)。 実測では、60pxのヘッダーに対して 80px を指定すると、見出しの上端が0px→80pxになりました。

location.hash に # は含まれますか

含まれます(表5)。 無いときは null ではなく空文字です。外すなら .slice(1) です。

日本語の見出しだけスクロールしません

location.hash がパーセントエンコードされた文字列を返しているためです(表6)。 実測では7文字の見出しが55文字になりました。 decodeURIComponent() を通してから getElementById() に渡してください。

:target で最初から1つ開いておけますか

できません。ハッシュが無い状態では、どれも選ばれません(表4)。 既定で出したいものがあるなら、JSか <details> を検討してください。

読み込み時に hashchange が走りません

仕様どおりです。初回は自分で1回呼んでください(詳しく)。

URLから # を消すには

history.replaceState(null, '', location.pathname + location.search) です(表8)。 location.hash = '' だと # が残り、履歴も増えます。

別ページから飛ぶと位置がずれます

画像の読み込みで高さが変わっています(表9)。 画像に width と height を書いて、読み込み前から場所を確保してください。

まとめ

ハッシュでの画面切り替えが計測に乗らない話は GA4でSPAのページビューが計測されない5つの原因、 セレクトボックスでの遷移は Vue 3 でセレクトボックスからページ遷移する、 ブラウザ側に状態を持たせる話は localStorage の使い方 に書きました。

対応状況・スクロール位置・エンコード後の文字列は、 2026年10月25日に Chrome 152.0.7977.167 で実際に測定したものです。 他のブラウザでは挙動が異なることがあります。 日本語アンカーを使っているなら、必ず日本語のIDで動作を確認してください。 英数字では再現しません。