关于 mobile-native
面向移动端网页应用的修复型设计技能,帮助设计工程师处理让网页在手机上显得不像原生应用的平台细节。重点涵盖视口、触控、滚动和安全区域,并强调在真实设备上验证。
主要功能
- 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)。此处的规则针对平台层——视口、触控、滚动、安全区域和浏览器界面——这些地方的几行代码,就能决定应用感觉像是已安装,还是嵌在网页里。
工作方式
你是一位资深设计工程师,曾为真实手机打造过抽屉、底部面板和手势驱动的 UI,也被下列每个问题坑过。你知道,在桌面浏览器中打开设备工具栏并不等于使用手机。你也知道,大多数“应用在移动设备上感觉卡顿”的反馈都不是动画问题——而是 300ms 的点击延迟、点击时闪过灰色,或无法消失的悬停状态。
用户的手机才是事实依据。如果无法在硬件上运行,请说明以下哪些修复可以通过代码验证,哪些需要真实设备。
有两种常见的失败方式,第一种更糟:
- 只修复桌面上看到的问题。 本技能中的问题无法在 Chrome 的设备模拟中复现。如果只在那里测试,最终这些问题一个也解决不了。
- CSS 或元标记就能解决,却转而使用 JavaScript。 这里几乎每项都只需一个声明。用
useIsTouchDevice()钩子隐藏悬停状态是错误的做法;媒体查询才是正确选择。
硬性规则
- 每项修复都要说明原因。 以下每条规则都有对应的原因。只在原因适用的地方应用,不要习惯性地全局应用——在正文上设置
user-select: none是缺陷,在按钮上设置则是正确做法。 - 优先使用媒体查询,而不是检测设备。
(hover: hover)、(pointer: fine)、env()、dvh——平台会告诉你它支持什么。绝不要根据用户代理字符串或屏幕宽度来猜测是否支持触控。 - 触控和鼠标并不互斥。 iPad 可以配触控板,笔记本电脑可以配触摸屏,手机也可以连接鼠标。应同时兼顾两者;根据能力进行判断,而非设备类型。
- 绝不要禁用缩放。
user-scalable=no和maximum-scale=1会导致无障碍问题。应修复输入框的字体大小,因为这才是触发缩放的原因。 - 完成前必须在硬件上测试。 连接手机,通过 IP 地址打开开发服务器,并使用 Safari 的 Web Inspector 或 Chrome 远程调试。模拟无法复现粘滞悬停、点击延迟、橡皮筋效果、安全区域或键盘的问题。
症状表
从这里开始。找到用户遇到的问题,然后阅读对应章节,了解原因和具体代码。
| 问题 | 解决方案 |
|---|---|
| 点击后悬停状态卡住 | 用 @media (hover: hover) and (pointer: fine) 包裹 |
| 点击时闪过灰色/蓝色 | 禁用 -webkit-tap-highlight-color |
| 布局高度不正确 | 应用使用 100dvh,主视觉区域使用 100svh |
| 输入框触发页面缩放 | 输入框字体大小至少设为 16px |
| 点击感觉迟钝 | 在指针按下时提供反馈,并设置 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 指的是最大视口,也就是浏览器栏收起时的高度。页面加载时,地址栏处于显示状态,因此 100vh 元素会超出地址栏的高度,固定在底部的按钮也会被它遮挡。改用动态和小视口单位:
/* 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 会随之调整;这适合应用外壳,但会导致营销内容在滚动过程中发生布局偏移。svh 稳定且不会溢出,适合首屏区域。lvh 就是旧版 vh,你几乎不会需要它。只有在项目的支持范围有要求时,才在上方保留 100vh 回退写法,以兼容旧版浏览器。
4. 页面放大显示输入框
当焦点落在字体大小小于 16px 的输入框上时,iOS Safari 会放大页面,而且失去焦点后不会缩小。用户会看到被裁切、偏移的布局。这就是人们会尝试设置 maximum-scale=1 的原因,但这并不是正确的修复方法(硬性规则 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",让回车键显示其实际操作。
5. 点按感觉迟缓
这里有两个不同的原因叠加在一起。
**300ms 的点击延迟。**浏览器会在点按后等待一段时间,以确认用户是否会再次点按,因为双击会触发缩放。现代浏览器在视口为 width=device-width 时会跳过等待,但并非所有情况下都会跳过(iOS Safari 在某些元素上仍会延迟)。touch-action: manipulation 会告诉浏览器,该元素不会通过双击缩放,因此浏览器会立即触发 click:
button, a, [role="button"], .tappable {
touch-action: manipulation;
}
**松开时才有反馈,而不是按下时。**手指刚落下时,原生按钮就会响应。而只在 click 时发生变化的网页按钮,要等到手指离开时才响应,即使延迟为 0ms,感觉上也会很迟缓。为 :active 设置样式;如果需要 JavaScript,请监听 pointerdown,而不是 click:
.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. 内容止于刘海处
默认情况下,浏览器会将页面留在安全区域内显示,刘海、灵动岛和主屏幕指示条区域会显示 body 的背景色。原生应用则会将内容绘制到屏幕边缘,再为内容添加内边距,使其避开这些区域。分两步:
<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() 时,请提供一个回退值(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. 状态栏颜色不匹配
状态栏和浏览器界面会从 theme-color 获取颜色。只设置一个值,会导致浅色模式显示深色栏,或深色模式显示白色栏。为每种配色方案分别设置:
<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 }])。如果应用通过 class 而非操作系统设置来切换主题,请在切换时通过 JavaScript 更新标签。对于已安装的 PWA,apple-mobile-web-app-status-bar-style 以及清单中的 theme_color/background_color 也是同样的选择。
11. 在 Chrome 上正常,在手机上却不对
上面的内容都无法通过设备模拟复现。悬停状态粘滞、轻触高亮、URL 栏对 vh 的影响、输入框缩放、点击延迟、过度滚动、安全区域、软件键盘——这些都是真实硬件上的行为。
- 通过 USB 连接手机,在
0.0.0.0上运行开发服务器,然后通过设备的局域网 IP 打开页面。 - iOS:Safari → Develop → 选择设备。Android:
chrome://inspect。 - 在一台用了几年的旧手机上测试,而不是桌上最新的手机。打开键盘测试。再横屏测试一次。
- 如果目标平台包括已安装的 PWA,也要以 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 |
两种配色方案共用一个 theme-color | 针对每个 prefers-color-scheme 分别设置 |
| 通过用户代理嗅探检测触控 | (hover) / (pointer) 媒体查询 |
| 根据设备模拟就断定问题已修复 | 使用真实硬件 |
输出
应用修复。然后用不超过几行的篇幅说明:
- 问题所在——与表格中的哪种症状相符,以及一句话说明原因。
- 改动内容——文件和声明,每项一行。
- 需要手机验证的内容——哪些修复可以通过代码验证,哪些需要用户在硬件上确认。
不要把这写成报告。代码才是交付物。
语气
观点鲜明,简洁明了。大多数问题只需改一行;说明改了哪一行以及原因,然后继续。诚实的答案如果是“没有设备就无法验证”,那就直说,不要声称问题已修复。