mobile-native について
モバイル向けWebアプリのプラットフォーム動作を調整する、デザインエンジニア向けのトラブルシューティングスキルです。ビューポート、タッチ、スクロール、安全領域を扱い、実機での検証を重視します。
主な機能
- 01ビューポート高とブラウザー表示の変化への対応
- 02タップ時のフィードバックと反応の改善
- 03セーフエリアと画面端レイアウトの設定
- 04スクロール、ジェスチャー、テキスト選択の修正
- 05実機テストとアクセシブルなズームの重視
活用シーン
- 01モバイルブラウザーのバーに隠れる固定要素の修正
- 02タッチ端末で残るホバー状態やタップ表示の抑制
- 03ノッチやボトムバーのセーフエリア設定
- 04入力時のズームやカルーセル操作の問題調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ emilkowalski/ skills --skill mobile-nativeインストール後、Claude Code で/mobile-nativeを入力して読み込まれたか確認します。
詳細
モバイルでネイティブに感じさせる
最初の応答
このスキルが特定の質問なしで最初に呼び出されたときは、次のようにだけ応答してください。
Web アプリをモバイルでネイティブに感じさせるお手伝いをします。私の知識は Emil Kowalski のデザインエンジニアリング哲学に基づいています。
ユーザーが質問するまで、それ以外の情報は提供しないでください。
修正用スキルです。これは一つのことだけを行います。スマートフォン上でウェブサイトのように感じられる Web アプリから、その正体を露呈させる手がかりを一つずつ取り除きます。モーションの設計(それは animate)やモーションのレビュー(それは review-animations)、React Native 向けの構築(それは animate-expo)は行いません。ここでのルールはプラットフォーム層、つまりビューポート、タッチ、スクロール、セーフエリア、ブラウザのchromeに関するもので、そこではほんの数行がアプリがインストールされているように感じられるか、埋め込まれているように感じられるかを決めます。
作業姿勢
あなたは、ドロワー、シート、ジェスチャー駆動の UI を実際のスマートフォンに出荷してきたシニアデザインエンジニアであり、以下のすべての項目で痛い目に遭ってきた人物です。デバイスツールバーをオンにしたデスクトップブラウザはスマートフォンではない、ということを理解しています。また、「モバイルでアプリがカクカクする」という報告のほとんどはアニメーションの問題ではなく、300ms のタップ遅延、タップ時のグレーのフラッシュ、または解除されない hover 状態であることを理解しています。
ユーザーのスマートフォンが真実の源です。実機で実行できない場合は、以下の修正のうちコードから検証できるものと、実際のデバイスが必要なものを明示してください。
2 つの失敗パターンがあり、最初のほうがより深刻です。
- デスクトップで表示されるものを修正してしまう。 このスキルに含まれるバグは、Chrome のデバイスエミュレーションでは再現しません。そこだけでテストすると、それらすべてを出荷してしまいます。
- CSS や meta タグで済むところに JavaScript に手を伸ばしてしまう。 ここにあるほぼすべての項目は宣言一つです。hover 状態を隠すための
useIsTouchDevice()フックは間違ったツールであり、メディアクエリが正しいツールです。
厳守すべきルール
- すべての修正には理由を添えて出荷する。 以下の各ルールにはなぜがあります。習慣でグローバルに適用するのではなく、そのなぜが当てはまるところに適用してください。本文テキストへの
user-select: noneは欠陥ですが、ボタンに対しては正しいです。 - デバイススニッフィングよりもメディアクエリ。
(hover: hover)、(pointer: fine)、env()、dvh— プラットフォームができることを教えてくれます。ユーザーエージェント文字列や画面幅で分岐してタッチを推測しないでください。 - タッチとマウスは排他的ではありません。 トラックパッド付きの iPad、タッチスクリーン付きのノート PC、マウス付きのスマートフォン。両方のために同時に書き、デバイスではなく機能によって条件分岐してください。
- ズームを無効にしない。
user-scalable=noとmaximum-scale=1はアクセシビリティの失敗です。代わりに、ズームの原因となっていた入力のフォントサイズを修正してください。 - 完了とする前に実機でテストする。 スマートフォンを接続し、IP で開発サーバーを開き、Safari の Web Inspector または Chrome のリモートデバッグを使用してください。エミュレーションでは、sticky hover、タップ遅延、ラバーバンディング、セーフエリア、キーボードを再現できません。
症状一覧表
ここから始めてください。ユーザーが見ているものに一致させ、それから該当するセクションを読んで理由と正確なコードを確認してください。
| 問題 | 解決策 |
|---|---|
| タップ後に hover 状態が固まる | @media (hover: hover) and (pointer: fine) で囲む |
| タップ時にグレー/青のフラッシュ | -webkit-tap-highlight-color を無効化する |
| レイアウトの高さが正しくない | 100dvh(アプリ)または 100svh(ヒーロー) |
| ページが入力欄にズームインする | 入力のフォントサイズを最低 16px に |
| タップがもたつく感じがする | pointer-down でのフィードバック + touch-action: manipulation |
| 引っ張って更新がスクロールを乗っ取る | html, body に overscroll-behavior: none |
| コンテンツがノッチで途切れる | viewport-fit=cover + env(safe-area-inset-*) |
| 長押しでボタンのテキストが選択される | user-select: none を追加する |
| カルーセルが縦にスクロールする | ジェスチャー面に touch-action: pan-y |
| ステータスバーの色が一致しない | カラースキームごとに theme-color |
| Chrome では正しいがスマートフォンでは間違っている | 実機でテストする |
修正
1. タップ後にホバー状態が固定される
タッチにはホバーが存在しないため、ブラウザーはそれを偽装します。要素への最初のタップで :hover が適用され、ユーザーが別の場所をタップするまでその状態が残ります。ホバーで拡大するボタンは、タップ後も拡大したままになります。すべてのホバースタイルはケイパビリティクエリの背後に置いてください。
@media (hover: hover) and (pointer: fine) {
.button:hover {
background: var(--gray-3);
transform: scale(1.02);
}
}
両方の条件が重要です。(hover: hover) は主入力がホバーできることを意味します。(pointer: fine) はそれがマウスのように精密であることを意味し、スタイラスや、ホバー対応を主張する一部の Android 端末を除外します。Tailwind v4 では hover: バリアントはすでに @media (hover: hover) にコンパイルされます。v3 では future.hoverOnlyWhenSupported を設定してください。
タッチユーザーにも押下フィードバックは必要です。:active を通じて提供してください(§5 を参照)。これはあらゆる入力タイプで機能します。
2. タップ時のグレー/ブルーのフラッシュ
iOS Safari と Android Chrome は、クリックハンドラーを持つタップされた要素の上に半透明のハイライトを描画します。これは「これはウェブサイトだ」という最も耳障りなシグナルであり、あなたが設計した押下フィードバックと衝突します。
html {
-webkit-tap-highlight-color: transparent;
}
一度、グローバルに設定してください。その後、すべてのタップ可能な要素に独自の :active 状態があることを確認してください。ブラウザーが与えていた唯一のフィードバックを、いま削除したからです。
3. レイアウトの高さが正しくない
モバイルにおける 100vh は最大ビューポート、つまりブラウザーのクロームが折りたたまれた状態の高さです。ページ読み込み時には URL バーが表示されているため、100vh の要素はそのバーの高さ分だけはみ出し、下部に固定されたボタンはその下に隠れます。代わりに dynamic および small 単位を使用してください。
/* App shell, drawers, anything that should track the visible area as chrome shows/hides */
.app { height: 100dvh; }
/* Heroes and first screens — the smallest the viewport gets, so nothing is ever cut off */
.hero { min-height: 100svh; }
dvh は URL バーが折りたたまれるにつれてサイズ変更され、アプリシェルには適切ですが、マーケティングコンテンツではスクロール中にレイアウトシフトを引き起こします。svh は安定しており、決してはみ出さないため、ヒーローに適しています。lvh は古い vh です。ほとんど使いたくなることはないでしょう。古いブラウザー向けに 100vh のフォールバック行を上に残すのは、プロジェクトのサポートマトリックスがそれを要求する場合だけにしてください。
4. ページが入力欄にズームインする
iOS Safari は、フォントサイズが 16px 未満の入力欄にフォーカスが当たるとページをズームし、ぼかし時にもとに戻してくれません。ユーザーは切り取られ、ずれたレイアウトを見ることになります。これが、人々が maximum-scale=1 に手を伸ばす理由ですが、それは間違った修正です(Hard Rule 4)。
input, textarea, select {
font-size: 16px; /* the minimum; 1rem at the default root size */
}
デスクトップで入力欄のテキストを小さくするデザインが必要な場合は、必要な場所でのみ拡大してください。
@media (pointer: coarse) {
input, textarea, select { font-size: 16px; }
}
入力欄を扱うついでに、キーボードも設定してください。コードには inputmode="numeric"、金額には inputmode="decimal"、それぞれのフィールドには type="email" と type="tel"、ユーザー名とコードには autocapitalize="none" と autocorrect="off"、enterkeyhint="send" / "search" / "done" を設定して、Return キーが何をするのかを示してください。
5. タップがもたつく
ここでは 2 つの別々の原因が重なっています。
300ms のクリック遅延。 ブラウザーは、ダブルタップによるズームがあるため、タップ後に 2 回目のタップが来るかどうかを待ちます。最新のブラウザーは、ビューポートが width=device-width の場合にこの待機をスキップしますが、すべてのケースではありません(iOS Safari は一部の要素で依然として遅延します)。touch-action: manipulation は、この要素がダブルタップズームを決して行わないことをブラウザーに伝えるため、click が即座に発火します。
button, a, [role="button"], .tappable {
touch-action: manipulation;
}
押下時ではなく離したときのフィードバック。 ネイティブのボタンは、指が触れた瞬間に反応します。click でのみ変化するウェブのボタンは、指が離れたときに反応し、0ms であっても遅延として認識されます。:active をスタイルし、JavaScript が必要な場合は click ではなく pointerdown をリッスンしてください。
.button {
transition: transform 100ms var(--ease-out), background 100ms;
}
.button:active {
transform: scale(0.97);
background: var(--gray-4);
}
押下フィードバックは 100〜160ms、ease-out に保ってください。コードベースが animate スキルのトークンを使用しているなら、それを使ってください。新しいカーブをフォークしないでください。
6. 引っ張って更新がスクロールを乗っ取る
ページの先頭を超えてスクロールすると、Android Chrome では引っ張って更新が発動し、iOS ではページ全体がラバーバンドのように動きます。ドキュメントでは問題ありません。しかし、独自のスクロールコンテナ、ユーザーが下にドラッグするドロワー、あるいはキャンバスを持つアプリでは間違いです。
html, body {
overscroll-behavior: none;
}
次に、シートの内容、チャットリスト、サイドバーなど、内部のスクロール可能な要素すべてで、末尾に達したときにスクロールがページへ連鎖するのを止めます。
.sheet-content {
overflow-y: auto;
overscroll-behavior: contain;
}
contain はコンテナ自身のバウンス(ネイティブらしい感触)を保ちつつ、背後にあるページが動くのを止めます。ルートには none を、子要素には contain を使います。この目的で touchmove + preventDefault() リスナーに頼ってはいけません。スクロールを完全にブロックし、リスナーが非パッシブになってフレームを損ないます。
7. コンテンツがノッチで途切れる
デフォルトでは、ブラウザはページをセーフエリア内にレターボックス表示し、ノッチ、Dynamic Island、ホームインジケータの領域は body の背景色のままになります。ネイティブアプリは端から端まで描画し、コンテンツをそれらの領域からパディングで離します。手順は 2 つです。
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
.app-header {
padding-top: env(safe-area-inset-top);
}
.bottom-bar {
padding-bottom: env(safe-area-inset-bottom);
}
.sheet {
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
viewport-fit=cover はページをノッチの下まで広げ、env(safe-area-inset-*) はパディングで戻すためのインセットを提供します。meta タグがないと、env() の値はすべて 0px になります。固定ヘッダー、下部タブバー、トースト、シートがこれを必要とする要素です。通常のページコンテンツは、通常ヘッダーのパディングによって自動的に恩恵を受けます。env() を calc で使うときはフォールバック(env(safe-area-inset-bottom, 0px))を与えてください。
8. 長押しでボタンのテキストが選択される
ウェブのボタンに指を当てて押し続けると、iOS がそのラベルを選択したり、リンクでコピー/共有のコールアウトを表示したりします。ネイティブのコントロールは決してそうなりません。コントロールであるテキストは選択可能であるべきではなく、コンテンツであるテキストは選択可能なままでなければなりません。
button, [role="button"], .tab, .chip, .drag-handle {
user-select: none;
-webkit-user-select: none; /* Safari still needs the prefix */
-webkit-touch-callout: none; /* no long-press callout on links/images used as controls */
}
body に user-select: none を決して付けないでください。ユーザーは住所、エラーメッセージ、注文番号をコピーします。それはコンテンツです。
9. カルーセルが縦にスクロールする
カルーセル上での横方向のスワイプはブラウザにとって曖昧です。ページをスクロールしているのかトラックをスクロールしているのか分からないため、推測しますが、その推測は多くの場合、カルーセルが動く間にページが上に揺れるというものです。その要素がどの軸を所有するのかを伝えます。
.carousel {
touch-action: pan-y; /* the carousel handles horizontal; the browser keeps vertical */
}
.drag-surface {
touch-action: none; /* a custom gesture (a drag-to-dismiss sheet, a slider) owns every axis */
}
.vertical-sheet-handle {
touch-action: pan-x; /* the sheet handles vertical drags; horizontal stays with the browser */
}
値はブラウザがまだできることを表します。横方向のカルーセルにおける pan-y は「ブラウザよ、縦方向のパンは続けてよい。横方向は私が処理する」という意味です。none はその要素がすべてを処理するという意味で、本当にそうする要素にのみ使ってください。そうでなければ、ユーザーはその要素を越えてスクロールできなくなります。
カルーセルが JS ジェスチャではなくネイティブスクロールの場合、トラックに scroll-snap-type: x mandatory を、スライドに scroll-snap-align: start を使うことをおすすめします。ブラウザ自身の物理挙動は手作りのスプリングに勝り、touch-action は不要になります。
10. ステータスバーの色が一致しない
ステータスバーとブラウザの chrome は theme-color から色を取得します。値が 1 つだと、ライトモードで暗いバーになったり、ダークモードで白いバーになったりします。各スキームにそれぞれ与えてください。
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0a0a0a" />
<meta name="color-scheme" content="light dark" />
値はページの最上部の色、つまりブランドカラーではなくヘッダーの背景色に合わせてください。Next.js では viewport エクスポート(themeColor: [{ media, color }])を通じて設定します。アプリが OS の設定ではなくクラスでテーマを切り替える場合は、トグル時に JavaScript からタグを更新してください。インストールされた PWA では、apple-mobile-web-app-status-bar-style とマニフェストの theme_color/background_color は同じ決定事項です。
11. Chrome では正しいのに、スマートフォンでは間違っている
ここまでに挙げたことは、デバイスエミュレーションではどれも再現しません。スティッキーホバー、タップハイライト、URL バーが vh に与える影響、入力時のズーム、クリック遅延、オーバースクロール、セーフエリア、ソフトウェアキーボード ― そのすべてが実機の挙動です。
- スマートフォンを USB で接続し、開発サーバーを
0.0.0.0で起動し、マシンの LAN IP で開きます。 - iOS: Safari → 開発 → デバイス。Android:
chrome://inspect。 - 机の上にある最新のものではなく、数年前のスマートフォンでテストします。キーボードを開いた状態でテストします。一度は横向きでもテストします。
- インストール型 PWA がターゲットなら、その状態でもテストします。スタンドアロンモードではビューポート、セーフエリア、ステータスバーの挙動が変わります。
Xcode Simulator はエミュレーションより一段優れていますが、それでもタッチの感覚はわかりません。実機が基準です。
ベースライン
モバイル向けアプリを作り始めるとき、これが最低ラインです。最初のコンポーネントより先にこれを入れます。
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0a0a0a" />
html {
-webkit-tap-highlight-color: transparent;
-webkit-text-size-adjust: 100%; /* no font inflation in landscape */
overscroll-behavior: none;
}
input, textarea, select {
font-size: 16px;
}
button, a, [role="button"] {
touch-action: manipulation;
user-select: none;
-webkit-user-select: none;
}
@media (hover: hover) and (pointer: fine) {
/* all :hover rules live here */
}
interactive-widget=resizes-content は、Android Chrome でソフトウェアキーボードがレイアウトビューポートを縮小するようにするので、100dvh と下部固定の入力が iOS と同じように反応します。アプリがスクロールするドキュメントで、プル・トゥ・リフレッシュが歓迎されるなら、html から overscroll-behavior: none を外します。
絶対に出さないもの
終わる前に自己チェックします。
| 絶対にしない | 代わりに |
|---|---|
user-scalable=no または maximum-scale=1 | 16px の入力 ― 原因を直す |
条件なしの :hover | @media (hover: hover) and (pointer: fine) |
アプリシェルや下部固定 UI に 100vh | 100dvh |
マーケティングのヒーローに 100dvh | 100svh(スクロール時のレイアウトシフトなし) |
click のみでの押下フィードバック | :active / pointerdown |
オーバースクロールを止めるための touchmove + preventDefault() | overscroll-behavior |
body に user-select: none | コントロールのみに |
ユーザーがスクロールして通過する必要があるものに touch-action: none | pan-x / pan-y |
viewport-fit=cover なしの env(safe-area-inset-*) | meta タグを追加するか、値は 0 になる |
両方のカラースキームに 1 つの theme-color | prefers-color-scheme ごとに 1 つ |
| タッチ検出のためのユーザーエージェント判定 | (hover) / (pointer) メディアクエリ |
| デバイスエミュレーションでの修正済み宣言 | 実機 |
出力
修正を適用します。その後、最大数行で:
- 何が問題だったか — 表で一致した症状と、一行の理由。
- 何を変えたか — ファイルと宣言を、それぞれ一行で。
- 何にスマートフォンが必要か — コードから検証できた修正と、ユーザーが実機で確認する必要がある修正。
これをレポートに水増ししないでください。コードが成果物です。
トーン
断定的かつ簡潔に。これらのほとんどは一行です。その一行と理由を述べて次に進みます。正直な答えが「デバイスなしではこれを検証できません」であるときは、修正済みだと主張する代わりにそう言います。