URLのアンカー(ハッシュ)で処理を分ける — その多くは、もうJavaScriptが要らない
先に結論
「URLの # で処理を分ける」という作業の大半は、いまCSSだけで書けます。
JavaScriptを書く前に、下の表を見てください。
| やりたいこと | 昔 | いま |
|---|---|---|
| ページ内リンクをなめらかに動かす | JSでアニメーション | scroll-behavior: smooth(CSS) |
| 固定ヘッダーで見出しが隠れる | JSでオフセット計算 | scroll-margin-top(CSS1行) |
| ハッシュに応じて表示を切り替える | JSで display を操作 | :target(CSS) |
| ハッシュの値を読んで処理を分ける | location.hash | location.hash(いまも同じ) |
| ハッシュの変化を検知する | hashchange | hashchange(いまも同じ) |
手元のChrome 152で、CSSの3つが使えることを確認しました。
| 機能 | CSS.supports() |
|---|---|
scroll-behavior: smooth | true |
scroll-margin-top | true |
:target | true |
そして、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の固定ヘッダーを置いたページで、実際に測りました。
| 指定 | 見出しの上端 | 見え方 |
|---|---|---|
| なし | 0px | ヘッダー(60px)の裏に隠れる |
scroll-margin-top: 80px | 80px | ヘッダーの下に出る |
書き方は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>
| 場面 | 向くか | 理由 |
|---|---|---|
| URLで状態を共有したい | 向く | URLがそのまま状態になる |
| JSを使いたくない | 向く | スクリプトが落ちても動く |
| 戻るボタンで前の状態に戻したい | 向く | 履歴に残る |
| 初期表示で何かを出しておきたい | 向かない | ハッシュが無い状態では、どれも選ばれない |
| 複数を同時に開きたい | 向かない | :target は1つだけ |
| アコーディオン(開閉) | 向かない | <details> のほうが素直 |
4行目が :target の弱点です。
ハッシュが無い状態では何も選ばれないので、「最初はAを出しておく」が書けません。
既定で出したいものがあるなら、JSか <details> を検討してください。
それでもJSが要る場合
location.hash の値の形
location.hash は # を含みます。
ここを勘違いすると、比較がすべて外れます。実測した値です。
| 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文字)の要素に飛んだとき、実際に返ってきた値です。
| 見るもの | 値 |
|---|---|
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() した結果 | #日本語見出し |
そのまま要素を探すと、見つかりません。
| 書き方 | 結果 |
|---|---|
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 = '' だと、# だけが残ります。
| 書き方 | 結果 | 履歴 |
|---|---|---|
location.hash = '' | # が残る | 増える |
history.replaceState(null, '', location.pathname + location.search) | きれいに消える | 増えない |
history.pushState(...) | 消える | 増える(戻るで復活) |
2行目を使ってください。
replaceState なら履歴を増やさないので、戻るボタンが壊れません。
踏みやすい罠
| 症状 | 原因 |
|---|---|
| 日本語の見出しだけ飛ばない | decodeURIComponent() を通していない(表7) |
| 比較が常に false | location.hash には # が含まれる(表5) |
| 読み込み時に処理が走らない | hashchange は初回発火しない(詳しく) |
| 見出しがヘッダーに隠れる | scroll-margin-top を付けていない(表3) |
| 戻るボタンが効かなくなる | ハッシュを消すのに pushState を使っている(表8) |
| 同じページ内なのに再読み込みされる | <a href="#"> のまま preventDefault() していない |
| 別ページから飛ぶと位置がずれる | 画像の読み込みで高さが変わっている。画像に width/height を書く |
最後の行は見落とされます。 アンカー付きのURLで直接開くと、スクロール位置を決めたあとに画像が読み込まれて高さが変わります。 画像に寸法を書いておけば、読み込み前から場所が確保されるので、ずれません。
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 を書いて、読み込み前から場所を確保してください。
まとめ
- 大半はCSSで足りる。スムーススクロール・固定ヘッダー対策・表示の出し分けは、いまJSが要らない。
- 固定ヘッダーで隠れる問題は
scroll-margin-topの1行。実測で見出しの上端が0px→80pxになった。 - MDNも「内容がヘッダーに隠れることを防ぎます」と用途を明記している。
- 表示の出し分けは
:target。ただしハッシュが無い状態では何も選ばれない。 location.hashには#が含まれる。無いときは空文字(nullではない)。- 日本語のIDはパーセントエンコードされる。実測で7文字が55文字になった。
- そのまま
getElementById()に渡すと見つからない。decodeURIComponent()が要る。 - 英数字のIDでテストしていると気づけない。日本語の見出しで試す。
hashchangeは読み込み時に発火しない。初回は自分で呼ぶ。#を消すならreplaceState。location.hash = ''だと#が残る。
ハッシュでの画面切り替えが計測に乗らない話は GA4でSPAのページビューが計測されない5つの原因、 セレクトボックスでの遷移は Vue 3 でセレクトボックスからページ遷移する、 ブラウザ側に状態を持たせる話は localStorage の使い方 に書きました。
対応状況・スクロール位置・エンコード後の文字列は、 2026年10月25日に Chrome 152.0.7977.167 で実際に測定したものです。 他のブラウザでは挙動が異なることがあります。 日本語アンカーを使っているなら、必ず日本語のIDで動作を確認してください。 英数字では再現しません。