「スマホのときだけメニューの動きを変えたい」「ダークモードのときはテーマを切り替えたい」——CSS のメディアクエリでできる判定を、JavaScript 側でもやりたくなる場面があります。そんなときに使うのが matchMedia です。この記事では、window.matchMedia() の基本の使い方から、change イベントでの変化の検知、MediaQueryList のプロパティ一覧、ダークモードやアニメーション抑制への応用、そして「change が発火しない」ときの原因までを解説します。
目次
画面幅の判定を innerWidth でやると二重管理になる
JavaScript で画面幅によって処理を分けたいとき、まず思いつくのが window.innerWidth との数値比較です。しかしこの書き方には、実務でじわじわ効いてくる問題が2つあります。
// よくある書き方。動くが、ブレークポイントが CSS と JS の2か所に散らばる
if (window.innerWidth <= 768) {
setupMobileMenu();
}
// しかも resize は動かしている間ずっと連続で発火する
window.addEventListener('resize', () => {
if (window.innerWidth <= 768) {
setupMobileMenu();
}
});
1つめはブレークポイントの二重管理です。CSS には @media (max-width: 768px) と書き、JS にも 768 と書く——この状態でデザイン変更が入ると、片方の修正を忘れて「見た目はスマホなのに JS は PC 用のまま」というズレが生まれます。さらに、CSS のメディアクエリは px 換算やスクロールバーの扱いがブラウザーの計算に従うため、innerWidth の値と1ピクセル単位でぴったり一致するとも限りません。
2つめはパフォーマンスです。resize はウィンドウを動かしている間じゅう何十回と発火しますが、実際に処理を切り替えたいのは「768px を横切った瞬間」の1回だけ。残りはすべて無駄な実行で、間引き(デバウンス)のコードを自分で書く羽目になります。
window.matchMedia() は、この両方を解決します。CSS とまったく同じメディアクエリ文字列を渡して判定でき、変化を通知する change イベントは一致・不一致が切り替わった瞬間だけ発火します。しかも判定できるのは幅だけではなく、prefers-color-scheme や orientation など、CSS のメディアクエリで書けるものはすべて対象です。
基本の使い方は matchMedia() に文字列を渡すだけ
window.matchMedia() にメディアクエリの文字列を渡すと、MediaQueryList というオブジェクトが返ります。あとはその matches プロパティを見れば、いま条件に一致しているかどうかが真偽値で分かります。
// CSS の @media に書くのと同じ文字列を渡す(@media は付けない)
const mq = window.matchMedia('(max-width: 768px)');
if (mq.matches) {
console.log('768px 以下です');
} else {
console.log('768px より広いです');
}
渡す文字列は CSS の @media のあとに続く部分と同じで、@media というキーワード自体は含めません。(min-width: 768px) and (max-width: 1024px) のような複数条件や、screen and (orientation: portrait) のようなメディア種別付きの指定もそのまま書けます。
matches はプロパティを読んだ時点の状態を返す、いわば「今どうなっているか」のスナップショットです。MediaQueryList は生きているオブジェクトなので、画面幅が変わったあとにもう一度 mq.matches を読めば、新しい値が返ってきます。毎回 matchMedia() を呼び直す必要はありません。
change イベントで「変わった瞬間」だけ処理する
状態が切り替わったことを知りたいときは、MediaQueryList に change イベントのリスナーを登録します。ハンドラーが受け取るイベントオブジェクト(MediaQueryListEvent)には matches と media が入っているので、切り替わった後の状態はそこから読めます。
const mq = window.matchMedia('(max-width: 768px)');
function handleChange(event) {
if (event.matches) {
console.log('スマホ幅になりました');
} else {
console.log('PC 幅になりました');
}
}
// 一致・不一致が切り替わったときだけ呼ばれる
mq.addEventListener('change', handleChange);
// 解除するときは、登録したのと同じ関数を渡す
mq.removeEventListener('change', handleChange);
ここが resize との決定的な違いです。ウィンドウを 1200px から 800px まで縮めても、(max-width: 768px) の判定は false のままなので change は一度も発火しません。768px を下回った瞬間に1回だけ呼ばれます。間引きのコードを自分で書く必要がないのは、この仕様のおかげです。
実際の動きを確かめてみてください。次のデモは (max-width: 500px) の判定結果を表示し、change が発火するたびにログを追記します。ブラウザーの幅を少しずつ変えても、境界をまたいだときにしかログが増えないことが分かります。
<div class="status">
<p class="status__label">(max-width: 500px) の判定結果</p>
<p id="result" class="status__value">判定中…</p>
</div>
<p class="hint">ブラウザーの幅を変えてみてください。プレビュー(iframe)の幅が 500px を上下したときだけ change が発火します。</p>
<pre id="log">change イベントを待っています。</pre>
body {
font-family: sans-serif;
margin: 16px;
}
.status {
padding: 16px;
border: 1px solid #ddd;
border-radius: 6px;
text-align: center;
}
.status__label {
margin: 0;
font-size: 13px;
color: #666;
}
.status__value {
margin: 8px 0 0;
font-size: 20px;
font-weight: bold;
}
/* JS が付け外しするクラスで見た目を変える */
body.is-narrow .status {
border-color: #007bff;
background: #eaf3ff;
}
body.is-narrow .status__value {
color: #007bff;
}
.hint {
font-size: 13px;
color: #666;
}
#log {
margin-top: 8px;
padding: 12px;
background: #f5f5f5;
border-radius: 6px;
white-space: pre-wrap;
font-size: 13px;
line-height: 1.7;
}
// CSS と同じメディアクエリ文字列をそのまま渡す
const mq = window.matchMedia('(max-width: 500px)');
const result = document.getElementById('result');
const log = document.getElementById('log');
function render(matches) {
result.textContent = matches
? 'matches: true(狭い画面)'
: 'matches: false(広い画面)';
document.body.classList.toggle('is-narrow', matches);
}
// 1. 読み込み時の状態は自分で matches を見る(change は発火しない)
render(mq.matches);
// 2. 一致・不一致が切り替わったときだけ通知される
mq.addEventListener('change', (event) => {
render(event.matches);
log.textContent += '\nchange: matches = ' + event.matches;
});
なお、以前は change イベントではなく addListener() / removeListener() という専用メソッドを使うのが一般的でした。これらは現在非推奨で、MDN でも通常の addEventListener() / removeEventListener() を使うよう案内されています。MediaQueryList が EventTarget を継承するようになったためで、他のイベント処理と同じ書き方に統一できるようになりました。古い記事のコードをコピーしたときは、この2つのメソッドが残っていないか確認しておくとよいでしょう。
MediaQueryList のプロパティとメソッド
matchMedia() が返す MediaQueryList が持っているものは多くありません。実質的には matches を読んで change を購読するだけの、シンプルなオブジェクトです。
| メンバー | 内容 |
|---|---|
matches | いまメディアクエリに一致しているかを表す真偽値(読み取り専用) |
media | 解釈されたメディアクエリの文字列(読み取り専用)。不正な文字列を渡した場合は "not all" になる |
addEventListener('change', fn) | 一致・不一致が切り替わったときに fn を呼ぶ。現在の推奨はこちら |
removeEventListener('change', fn) | 登録したリスナーを解除する。登録時と同じ関数の参照を渡す必要がある |
addListener(fn) | 非推奨。addEventListener('change', fn) の古い書き方 |
removeListener(fn) | 非推奨。removeEventListener('change', fn) の古い書き方 |
onchange | change のイベントハンドラーを1つだけ設定できるプロパティ |
ハンドラーに渡ってくる MediaQueryListEvent も、matches と media の2つを持っています。名前も意味も MediaQueryList 側とそろえてあるので、「初回は MediaQueryList を、以降はイベントオブジェクトを同じ関数に渡す」という書き方ができます。次の実践例で使うのがまさにその形です。
実践例1:スマホのときだけメニューの挙動を変える
いちばん多い用途が、画面幅によるナビゲーションの切り替えです。ここで大事なのは、初回の判定と change の処理を1つの関数にまとめること。MediaQueryList もイベントオブジェクトも同じ matches を持っているので、ハンドラーに MediaQueryList をそのまま渡せば、読み込み時の初期化を同じコードで済ませられます。
<nav class="nav">
<button id="toggle" class="nav__toggle" aria-expanded="false">☰ メニュー</button>
<ul id="menu" class="nav__list">
<li><a href="#">ホーム</a></li>
<li><a href="#">サービス</a></li>
<li><a href="#">お問い合わせ</a></li>
</ul>
</nav>
<p id="mode" class="mode"></p>
body {
font-family: sans-serif;
margin: 16px;
}
/* hidden 属性を必ず効かせる(display 指定に負けないように) */
[hidden] {
display: none !important;
}
.nav {
padding: 12px 16px;
border: 1px solid #ddd;
border-radius: 6px;
}
.nav__toggle {
padding: 8px 16px;
cursor: pointer;
}
.nav__list {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin: 0;
padding: 0;
list-style: none;
}
/* スマホ用のときは縦並びにする */
.nav__list.is-stacked {
display: block;
margin-top: 12px;
}
.nav__list.is-stacked li {
padding: 8px 0;
border-top: 1px solid #eee;
}
.mode {
font-size: 13px;
color: #666;
}
const mqMobile = window.matchMedia('(max-width: 600px)');
const toggle = document.getElementById('toggle');
const menu = document.getElementById('menu');
const mode = document.getElementById('mode');
// 初回判定と change を1つの関数にまとめる
function applyLayout(event) {
if (event.matches) {
// スマホ幅:ボタンで開閉するメニューにする
toggle.hidden = false;
toggle.setAttribute('aria-expanded', 'false');
menu.hidden = true;
menu.classList.add('is-stacked');
mode.textContent = '現在: スマホ用(ボタンで開閉)';
} else {
// PC幅:ボタンを消して常に表示する
toggle.hidden = true;
menu.hidden = false;
menu.classList.remove('is-stacked');
mode.textContent = '現在: PC用(常時表示)';
}
}
// MediaQueryList も matches を持つので、そのまま渡せる
applyLayout(mqMobile);
mqMobile.addEventListener('change', applyLayout);
toggle.addEventListener('click', () => {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!expanded));
menu.hidden = expanded;
});
スマホ幅では開閉ボタンを表示してメニューを閉じ、PC 幅ではボタンを隠してメニューを常に見せています。applyLayout(mqMobile) という1行が初回の初期化にあたり、以降は change から同じ関数が呼ばれます。処理が1か所にまとまっているので、条件を 768px に変えたくなっても直すのは文字列1つだけです。
気をつけたいのは、この関数が「切り替わったときに毎回」呼ばれる点です。イベントリスナーを追加する処理をここに書くと、幅を行ったり来たりさせるたびに二重・三重に登録されてしまいます。登録と解除を対で書くか、上のデモのように状態(クラスや属性)を書き換えるだけの実装にしておくのが安全です。
実践例2:prefers-color-scheme でダークモードに追従する
matchMedia の対象は画面幅だけではありません。OS やブラウザーのダークモード設定は (prefers-color-scheme: dark) で判定でき、設定が変わったときには change が飛んできます。配色だけなら CSS だけで完結しますが、「ライブラリに渡すテーマ名を切り替える」「Canvas の描画色を変える」といった JavaScript 側の処理が必要な場面で役立ちます。
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
function applyTheme(event) {
const theme = event.matches ? 'dark' : 'light';
// <html data-theme="dark"> のように属性で持たせておくと CSS 側から使いやすい
document.documentElement.dataset.theme = theme;
}
// 読み込み時に一度適用してから、以降の変化を購読する
applyTheme(darkMq);
darkMq.addEventListener('change', applyTheme);
実際のサイトでは「OS の設定に従う」だけでなく、ユーザーがサイト内でテーマを選べるようにすることが多いはずです。その場合は localStorage に保存した選択を優先し、保存がないときだけ matchMedia の結果を使う、という組み立てにします。
const darkMq = window.matchMedia('(prefers-color-scheme: dark)');
function currentTheme() {
const saved = localStorage.getItem('theme'); // 'dark' / 'light' / null
if (saved) return saved; // ユーザーの選択を最優先
return darkMq.matches ? 'dark' : 'light'; // 無ければ OS の設定に従う
}
document.documentElement.dataset.theme = currentTheme();
darkMq.addEventListener('change', () => {
// ユーザーが自分で選んでいるときは、OS の変化で上書きしない
if (localStorage.getItem('theme')) return;
document.documentElement.dataset.theme = currentTheme();
});
実践例3:prefers-reduced-motion でアニメーションを止める
OS には「視差効果を減らす」「アニメーションを減らす」といったアクセシビリティ設定があり、その状態は (prefers-reduced-motion: reduce) で判定できます。前庭障害などで動きが体調に影響する利用者のための設定なので、JavaScript で動かすアニメーションもこれを尊重するのが望ましい実装です。
const reduceMq = window.matchMedia('(prefers-reduced-motion: reduce)');
document.querySelector('#to-top').addEventListener('click', () => {
// 動きを控える設定なら、スムーズスクロールをやめて一瞬で移動する
window.scrollTo({
top: 0,
behavior: reduceMq.matches ? 'auto' : 'smooth',
});
});
ポイントは、判定をアニメーションを始める直前に読むことです。設定は途中で変わり得るため、ページ読み込み時に真偽値を変数へ控えてしまうと古い値を使い続けることになります。MediaQueryList を1つ作って使い回し、必要なタイミングで matches を読むのが確実です。ライブラリの初期化オプションのように後から変えられない場合は、change でアニメーションを停止する処理も足しておきます。
change が発火しない・初期状態が反映されないとき
matchMedia は API 自体が小さいぶん、つまずく箇所もほぼ決まっています。「動かない」と感じたときは、次の3つを順に確認してみてください。
読み込み時に何も起きない(change は変化したときだけ発火する)
もっとも多い勘違いがこれです。change は一致・不一致が切り替わった瞬間にしか発火しません。スマホでページを開いた場合、最初から (max-width: 768px) に一致しているので「切り替わり」は起きず、リスナーを登録しただけでは何も呼ばれないまま「スマホなのに PC 用の表示になる」ことになります。
初期状態は自分で matches を読んで反映させる必要があります。実践例1のように処理を関数へ切り出し、applyLayout(mq) と直接1回呼んでから mq.addEventListener('change', applyLayout) を登録する形にしておけば、書き忘れも防げます。
メディアクエリの文字列が正しくない
文字列を間違えても、matchMedia() は例外を投げません。解釈できないクエリは「決して一致しないクエリ」として扱われ、matches は常に false、change も永久に発火しないという、いちばん気づきにくい壊れ方をします。
典型的なのは、括弧を忘れた 'max-width: 768px'、単位を書き忘れた '(max-width: 768)'、そして CSS からコピーして @media まで含めてしまった '@media (max-width: 768px)' の3つです。疑わしいときは mq.media を出力してみてください。ここに "not all" と表示されたら、その文字列はブラウザーに読めていないという合図です。
console.log(window.matchMedia('(max-width: 768px)').media); // '(max-width: 768px)'
console.log(window.matchMedia('max-width: 768px').media); // 'not all' ← 壊れている
console.log(window.matchMedia('(max-width: 768)').media); // 'not all' ← 単位忘れ
removeEventListener で解除できない
リスナーを解除するには、登録したときと同じ関数の参照を渡さなければなりません。アロー関数をその場で書いて登録し、解除でも同じ見た目のアロー関数を書く——という書き方は、見た目が同じでも別のオブジェクトなので解除に失敗します。エラーにもならないため、リスナーが残り続けていることに気づきにくい点も厄介です。
// NG: 別々の関数オブジェクトなので解除されない
mq.addEventListener('change', (e) => update(e.matches));
mq.removeEventListener('change', (e) => update(e.matches));
// OK: 名前を付けて同じ参照を渡す
const onChange = (e) => update(e.matches);
mq.addEventListener('change', onChange);
mq.removeEventListener('change', onChange);
解除のタイミングをまとめて管理したいなら、AbortController を使う手もあります。addEventListener() の第3引数に { signal } を渡しておけば、controller.abort() の1回で登録したリスナーをすべて解除できます。SPA でコンポーネントを破棄するときなど、複数の MediaQueryList をまとめて片付けたい場面で便利です。
まとめ
window.matchMedia() は、CSS のメディアクエリと同じ文字列で条件判定を行うための API です。返ってきた MediaQueryList の matches で現在の状態を読み、addEventListener('change', fn) で切り替わりを購読する——覚えることはこの2つだけで、innerWidth と resize による自前判定の弱点だった二重管理と過剰な発火が同時に解消されます。
実装のコツは、初回の適用と change の処理を1つの関数にまとめ、MediaQueryList をそのまま渡して初期化することです。動かないときは mq.media が "not all" になっていないかを確認し、解除できないときはリスナーを名前付きの関数にする。判定対象は画面幅に限らず、prefers-color-scheme や prefers-reduced-motion のようなユーザー設定にも広げられるので、使いどころは思っているより多いはずです。