デフォルトでは機能検出を使いましょう。実際に必要な機能を 'IntersectionObserver' in window や CSS.supports('property', 'value') でテストし、user-agentチェックは、機能検出が本当に不可能な少数のiOSケースだけに限定します。
コードベースにiOSのバージョン番号の表を保持したことがある人なら、この流れをご存じでしょう。Appleがアップデートを出荷し、表は古くなり、誰かがチケットを作成する前に本番環境で何かが壊れる。機能(capability)チェックなら、このサイクルを完全に回避できます。
機能検出は、SafariとiOSにまつわる悩みのほとんどを解決します。壊れやすいバージョン表のメンテナンスをやめ、ブラウザに「何ができるのか」を尋ねるようになるからです。難しいのはその残余部分、つまり機能チェックが存在せず、慎重にsniffせざるを得ない少数のiOSエッジケース(デスクトップ版macOSとして報告される古いiPad、iOS 26のuser-agentフリーズ)です。本記事では、その両方に対する最新のプレイブックを紹介します。
要点
'x' in window、CSS.supports()、オプショナルチェイニングを使って、機能を直接検出しましょう。MDNはこれを、user-agent文字列のパースよりも「はるかに信頼できる戦略」と表現しています。- 「存在する」ことは「動作する」ことの証明ではありません。Safariは、実際には適用しない機能に対して
@supportsで true を返すことがあるため、既知の「嘘つき」機能については、要素を画面外にレンダリングしてgetBoundingClientRect()で計測しましょう。 - iOS上のすべてのブラウザはWebKit上で動作し、SafariのバージョンはOSのメジャーバージョンに連動しているため、
isMobileWebKit()チェックとCSS.supports()によるゲートを組み合わせれば、UAをパースせずにiOSバージョンを推測できます。 - iOS 26以降、SafariはUA内のOSトークンを26より前の値に固定しますが、その値自体も変動しています(
18_6→18_6_2→18_7)。決してハードコードせず、代わりにVersion/トークンをパースしましょう。 - このフリーズはSafari独自の挙動であり、iOSおよびiPadOS 26について文書化されています。iOS上のChromeとFirefoxは、依然として実際のiOSバージョンを報告します。
機能検出がデフォルト
ブラウザではなく、機能をテストしましょう。機能チェックはAppleがアップデートを出荷したときに自動的に適応し、メンテナンス用の表を必要とせず、エンジンをまたいで同一に動作します。これがまさに、AppleとMDNがuser-agent sniffingよりも機能検出を推奨する理由です。この作業のためのツールは3つあります。
JavaScript APIについては、グローバルを調べるか、オプショナルチェイニングを使います。
if ("IntersectionObserver" in window) {
// wire up lazy-loading
}
navigator.share?.({ title: "Modern feature detection" });
CSSについては、JS内では CSS.supports()、スタイルシート内では @supports アットルールを使います。
@supports (text-wrap-style: stable) {
h1 { text-wrap-style: balance; }
}
なお、navigator.userAgentData はここでの代替手段にはなりません。Chromium限定で実験的な機能とされているため、SafariとFirefoxは実装していません。ターゲットがiOS Safariである場合、これは機能検出の代替には決してなりません。
機能検出が嘘をつくとき:存在は証明ではない
Discover how at OpenReplay.com.
機能検出には、名前を付けておくべき失敗モードが2つあります。対処法が異なるからです。1つ目はアンチパターンで、機能Aを推測するために無関係な機能Bを検出することです。A Beautiful Siteが記録しているように、ブラウザが一方の機能を他方より先に出荷した瞬間、そのチェックは静かに壊れます。チェックを代理指標に結びつけてはいけません。
2つ目はより微妙です。存在は証明ではありません。Evil MartiansがCSSの safe アラインメントキーワードで発見したように、Safariは実際には適用しない値に対して @supports で true を返すことがあります。そうした既知の「嘘つき」については、サポートフラグを信用しないでください。要素を画面外にレンダリングし、getBoundingClientRect() で実際の結果を計測しましょう。
const supportsSafeAlign = () => {
const box = document.createElement("div");
const child = document.createElement("span");
child.textContent = "measure me";
Object.assign(box.style, {
display: "flex",
justifyContent: "safe center",
width: "5%",
position: "absolute",
top: "-9999px",
left: "-9999px",
});
box.appendChild(child);
document.body.appendChild(box);
const applied = child.getBoundingClientRect().left >= box.getBoundingClientRect().left;
document.body.removeChild(box);
return applied;
};
挙動テストは、正しくあるためにバージョンの申告を必要としません。実際にレンダリングされた結果を観測するからです。これはセッションリプレイが浮き彫りにするのが得意なタイプのバグです。つまり、テスト環境ではサポートチェックを通過するのに、実際のユーザーのiOSビルドでは静かに誤動作するゲート付き機能です。
機能検出によるiOSバージョンの検出
iOS上のすべてのブラウザはWebKit上で動作し、SafariのバージョンはOSのメジャーバージョンに紐づいているため、isMobileWebKit() チェックと、既知のSafariリリースで導入されたプロパティに対する CSS.supports() ゲートを組み合わせれば、user-agent文字列を一切パースせずにiOSバージョンを推測できます。モバイルWebKitのヒューリスティックは、WebKitが公開しているジェスチャーイベントを手がかりにします。
const isMobileWebKit = () => "ongesturechange" in window;
バージョンゲートについては、AppleのSafariリリースノートまたはMDNの互換性データで、あるプロパティが最初にサポートされたリリースを調べ、それをテストします。text-wrap-style ロングハンドはSafari 17.5で導入され、balance、stable、auto の各値が同時に出荷されたため、これらのいずれの値でもiOS 17.5以降を明確にゲートできます。
const isAtLeastIOS175 = () =>
window.CSS?.supports("text-wrap-style", "stable") ?? false;
出荷前に、プロパティ↔リリースの対応関係を自分で検証してください。リリースノートは変更点を省略していることがあり、「サポート済み」フラグは嘘をつくことがあります(前のセクションを参照)。isMobileWebKit() は仕様上の保証ではなく、強力なヒューリスティックとして扱いましょう。AppleはiOS 17.4以降、EUで代替ブラウザエンジンを許可しているため、「iOSならWebKit」は絶対ではなく、圧倒的にそうであるという程度の話です。
user-agent sniffingが範囲を限定した最後の手段になるとき
user-agentに頼るのは、2つの条件が同時に成立するときだけです。その機能に本当に機能テストが存在せず、かつ、推測を誤っても見た目上の不具合程度で済む場合です。iOSでは2つのケースがこの基準を満たします。
古いiPad。 機能検出ではiPadとMacを区別できません。iPadOS 13以降、iPadのデフォルトuser-agentはMacが送信するものと同じ文字列だからです。代わりに、シグナルを組み合わせましょう。デスクトップ版macOS Safariとして読めるuser-agentに、モバイルWebKitチェックが陽性で、タッチポイント数がゼロでないという条件が加われば、それはMacの服を着たiPadです。タッチポイントのテストは重要です。Macは navigator.maxTouchPoints を 0 と報告するため、デスクトップSafariでジェスチャーイベントのシグナルが存在する場合でも、本物のMacがマッチしないようにできます。
const looksLikeMacSafari = /Macintosh/.test(navigator.userAgent);
const isIPad = () =>
looksLikeMacSafari && isMobileWebKit() && navigator.maxTouchPoints > 0;
iOS 26のUAフリーズ。 iOS 26およびiPadOS 26では、Safariがuser-agentに稼働中のOSバージョンを入れるのをやめ、代わりにトークンを以前のリリースに固定しました。その固定値自体もポイントリリースをまたいで変動しており(ローンチ時は 18_6、次に Safari 26.1では 18_6_2、iOS 26.2以降は 18_7)、これがまさに、決してハードコードしてはならない理由です。実際のSafari(したがってiOS)のメジャーバージョンを今も反映している Version/ トークンをパースしましょう。
const iosMajor = () => {
const m = navigator.userAgent.match(/Version\/(\d+)/);
return m ? Number(m[1]) : null; // 26 on iOS 26.x Safari
};
これを補強するポイントが2つあります。第一に、このフリーズはSafari限定です。AppleInsiderによる独立したサーバーログ分析とLapcat Softwareの分析はいずれも、iOS上のChromeとFirefoxが依然として実際のOSバージョンを報告することを確認しています。つまり「iOS 26は検出できない」というのはSafari固有の問題であり、iOS全体の問題ではありません。第二に、パース処理を自前で抱えたくない場合、ua-parser-js の最新npmリリースは2.0.10なので、2.0.10+ にピン留めしてください。このパッケージにはサプライチェーンインシデントの履歴が記録されているため、インストールするものは確認しましょう。
プレイブック
全体の流れは短く、次の順序で実行します。
- まず機能検出。
'x' in window、CSS.supports()、@supports、オプショナルチェイニング。これでゲート判断の圧倒的大多数をカバーできます。 - 嘘つきには挙動テスト。 サポートフラグが信用できない場合は、画面外にレンダリングして
getBoundingClientRect()で計測します。 - UAなしでiOSバージョンを推測。
isMobileWebKit()と、リリースノートで確認済みのプロパティに対するCSS.supports()ゲートを併用します。 - 検出不能な端だけをsniff。 古いiPad向けの組み合わせ判定と、iOS 26フリーズ向けの
Version/トークン。sniffが失敗しても機能が失われない箇所でのみ使いましょう。 - 実機とシミュレータでテスト。 リリースノートもサポートフラグも隙間を残します。決着をつけられるのは実機だけです。
機能検出がデフォルトであるのは、想定していなかったアップデートを乗り越えられるからです。user-agent sniffingは、プラットフォームが本当に検出不能にしている2〜3のiOSケースに限定された例外です。この順序でチェックを組み込み、固定されたOS文字列ではなく Version/ トークンのパースに依拠し、出荷前に実際の古いiPadで結果を確認してください。
FAQ
user-agentのフリーズにもかかわらず、JavaScriptでiOS 26を検出できますか?
できます。iOS 26以降、SafariはUA内のOSトークンを26より前の値に固定しますが、その値はポイントリリースをまたいで変動しているため(18_6、次に18_6_2、iOS 26.2以降は18_7)、ハードコードすると失敗します。代わりにVersion/トークンをパースしましょう。これは今も実際のSafariのメジャーバージョン、したがってiOSのメジャーバージョンを報告します。iOS 26.xのSafariでは、Version/(\\d+)にマッチさせると26が返ります。
iOS 26のuser-agentフリーズは、iOS上のChromeとFirefoxにも影響しますか?
いいえ。AppleはOSトークンの固定を、iOSおよびiPadOS 26におけるSafari独自の挙動として文書化しています。AppleInsiderとLapcat Softwareによる独立したサーバーログ分析はいずれも、iOS上のすべてのブラウザがWebKit上で動作しているにもかかわらず、ChromeとFirefoxがuser-agent文字列で依然として実際のOSバージョンを報告していることを確認しています。したがって「iOS 26は検出できない」というのはSafari固有の問題であり、iOS全体の問題ではありません。
ブラウザ上でiPadとMacをどう見分けますか?
2つのシグナルを組み合わせます。機能検出だけでは区別できないからです。iPadOS 13以降、iPadのデフォルトuser-agentはMacが送信するものと同じ文字列です。user-agentがデスクトップ版macOS Safariのように見えるのに、モバイルWebKitチェックがタッチデバイス上であることを示す場合、それはMacに扮したiPadです。ジェスチャーイベントはデスクトップSafariにも現れるため、navigator.maxTouchPointsが0より大きいことを条件にしましょう。Macは0を報告します。
navigator.userAgentDataはSafariで動作しますか?
いいえ。navigator.userAgentDataはChromium限定で、MDNでは実験的とされているため、SafariとFirefoxは実装していません。ターゲットがiOS Safariである場合、機能検出の実用的な代替には決してなりません。代わりに'x' in windowやCSS.supports()で機能を直接テストしましょう。MDNはこれを、user-agentを読むよりもはるかに信頼できるアプローチと評価しています。