mobile-ux-optimizer
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMobile-First UX Optimization
移动端优先UX优化
Build touch-optimized, performant mobile experiences with proper viewport handling and responsive patterns.
打造经过触控优化、性能出色的移动端体验,包含正确的视口处理与响应式模式。
When to Use
适用场景
✅ USE this skill for:
- Viewport issues (problems, safe areas, notches)
100vh - Touch target sizing and spacing
- Mobile navigation patterns (bottom nav, drawers, hamburger menus)
- Swipe gestures and pull-to-refresh
- Responsive breakpoint strategies
- Mobile performance optimization
❌ DO NOT use for:
- Native app development → use or
react-nativeskillsswift-executor - Desktop-only features → no skill needed, standard patterns apply
- General CSS/Tailwind questions → use Tailwind docs or
web-design-expert - PWA installation/service workers → use skill
pwa-expert
✅ 适用该技能的场景:
- 视口问题(适配问题、安全区域、刘海屏)
100vh - 触控目标的尺寸与间距设置
- 移动端导航模式(底部导航、抽屉菜单、汉堡菜单)
- 滑动手势与下拉刷新
- 响应式断点策略
- 移动端性能优化
❌ 不适用的场景:
- 原生应用开发 → 使用或
react-native技能swift-executor - 仅桌面端功能 → 无需特殊技能,使用标准模式即可
- 通用CSS/Tailwind问题 → 使用Tailwind文档或技能
web-design-expert - PWA安装/Service Workers → 使用技能
pwa-expert
Core Principles
核心原则
Mobile-First Means Build Up, Not Down
移动端优先意味着向上构建,而非向下适配
css
/* ❌ ANTI-PATTERN: Desktop-first (scale down) */
.card { width: 400px; }
@media (max-width: 768px) { .card { width: 100%; } }
/* ✅ CORRECT: Mobile-first (scale up) */
.card { width: 100%; }
@media (min-width: 768px) { .card { width: 400px; } }css
/* ❌ ANTI-PATTERN: Desktop-first (scale down) */
.card { width: 400px; }
@media (max-width: 768px) { .card { width: 100%; } }
/* ✅ CORRECT: Mobile-first (scale up) */
.card { width: 100%; }
@media (min-width: 768px) { .card { width: 400px; } }The 44px Rule
44px规则
Apple's Human Interface Guidelines specify 44×44 points as minimum touch target. Google Material suggests 48×48dp.
tsx
// Touch-friendly button
<button className="min-h-[44px] min-w-[44px] px-4 py-3">
Tap me
</button>
// Touch-friendly link with adequate padding
<a href="/page" className="inline-block py-3 px-4">
Link text
</a>苹果人机界面指南规定最小触控目标为44×44点。谷歌Material设计建议为48×48dp。
tsx
// Touch-friendly button
<button className="min-h-[44px] min-w-[44px] px-4 py-3">
Tap me
</button>
// Touch-friendly link with adequate padding
<a href="/page" className="inline-block py-3 px-4">
Link text
</a>Viewport Handling
视口处理
The dvh
Solution
dvhdvh
解决方案
dvhMobile browsers have dynamic toolbars. includes the URL bar, causing content to be cut off.
100vhcss
/* ❌ ANTI-PATTERN: Content hidden behind browser UI */
.full-screen { height: 100vh; }
/* ✅ CORRECT: Responds to browser chrome */
.full-screen { height: 100dvh; }
/* Fallback for older browsers */
.full-screen {
height: 100vh;
height: 100dvh;
}移动端浏览器拥有动态工具栏。会包含地址栏,导致内容被截断。
100vhcss
/* ❌ ANTI-PATTERN: Content hidden behind browser UI */
.full-screen { height: 100vh; }
/* ✅ CORRECT: Responds to browser chrome */
.full-screen { height: 100dvh; }
/* Fallback for older browsers */
.full-screen {
height: 100vh;
height: 100dvh;
}Safe Area Insets (Notches & Home Indicators)
安全区域内边距(刘海屏与Home指示器)
css
/* Handle iPhone notch and home indicator */
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.header {
padding-top: env(safe-area-inset-top, 0);
}
/* Full safe area padding */
.safe-container {
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
}Required meta tag:
html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">css
/* Handle iPhone notch and home indicator */
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.header {
padding-top: env(safe-area-inset-top, 0);
}
/* Full safe area padding */
.safe-container {
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
}必填meta标签:
html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">Tailwind Safe Area Classes
Tailwind安全区域类
tsx
// Custom Tailwind utilities (add to globals.css)
@layer utilities {
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
.pt-safe { padding-top: env(safe-area-inset-top); }
.h-screen-safe { height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); }
}
// Usage
<nav className="fixed bottom-0 pb-safe bg-leather-900">
<BottomNav />
</nav>tsx
// Custom Tailwind utilities (add to globals.css)
@layer utilities {
.pb-safe { padding-bottom: env(safe-area-inset-bottom); }
.pt-safe { padding-top: env(safe-area-inset-top); }
.h-screen-safe { height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom)); }
}
// Usage
<nav className="fixed bottom-0 pb-safe bg-leather-900">
<BottomNav />
</nav>Mobile Navigation Patterns
移动端导航模式
Bottom Navigation (Recommended for Mobile)
底部导航(移动端推荐)
tsx
// components/BottomNav.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
const navItems = [
{ href: '/', icon: HomeIcon, label: 'Home' },
{ href: '/meetings', icon: CalendarIcon, label: 'Meetings' },
{ href: '/tools', icon: ToolsIcon, label: 'Tools' },
{ href: '/my', icon: UserIcon, label: 'My Recovery' },
];
export function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 bg-leather-900 border-t border-leather-700 pb-safe">
<div className="flex justify-around">
{navItems.map(({ href, icon: Icon, label }) => {
const isActive = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={`
flex flex-col items-center py-2 px-3 min-h-[56px] min-w-[64px]
${isActive ? 'text-ember-400' : 'text-leather-400'}
`}
>
<Icon className="w-6 h-6" />
<span className="text-xs mt-1">{label}</span>
</Link>
);
})}
</div>
</nav>
);
}tsx
// components/BottomNav.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
const navItems = [
{ href: '/', icon: HomeIcon, label: 'Home' },
{ href: '/meetings', icon: CalendarIcon, label: 'Meetings' },
{ href: '/tools', icon: ToolsIcon, label: 'Tools' },
{ href: '/my', icon: UserIcon, label: 'My Recovery' },
];
export function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 bg-leather-900 border-t border-leather-700 pb-safe">
<div className="flex justify-around">
{navItems.map(({ href, icon: Icon, label }) => {
const isActive = pathname === href || pathname.startsWith(`${href}/`);
return (
<Link
key={href}
href={href}
className={`
flex flex-col items-center py-2 px-3 min-h-[56px] min-w-[64px]
${isActive ? 'text-ember-400' : 'text-leather-400'}
`}
>
<Icon className="w-6 h-6" />
<span className="text-xs mt-1">{label}</span>
</Link>
);
})}
</div>
</nav>
);
}Slide-Out Drawer (Side Menu)
侧滑抽屉菜单
tsx
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Drawer({ isOpen, onClose, children }: DrawerProps) {
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className="absolute left-0 top-0 h-full w-[280px] max-w-[80vw]
bg-leather-900 shadow-xl transform transition-transform
animate-slide-in-left"
role="dialog"
aria-modal="true"
>
<div className="h-full overflow-y-auto pt-safe pb-safe">
{children}
</div>
</div>
</div>,
document.body
);
}tsx
'use client';
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
interface DrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
export function Drawer({ isOpen, onClose, children }: DrawerProps) {
// Prevent body scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Drawer */}
<div
className="absolute left-0 top-0 h-full w-[280px] max-w-[80vw]
bg-leather-900 shadow-xl transform transition-transform
animate-slide-in-left"
role="dialog"
aria-modal="true"
>
<div className="h-full overflow-y-auto pt-safe pb-safe">
{children}
</div>
</div>
</div>,
document.body
);
}Touch Gestures
触控手势
Full implementations inreferences/gestures.md
| Hook | Purpose |
|---|---|
| Directional swipe detection with configurable threshold |
| Pull-to-refresh with visual feedback and resistance |
Quick usage:
tsx
// Swipe to dismiss
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: () => dismiss(),
threshold: 50,
});
// Pull to refresh
const { containerRef, pullDistance, isRefreshing, handlers } =
usePullToRefresh(async () => await refetchData());完整实现请查看references/gestures.md
| Hook | 用途 |
|---|---|
| 可配置阈值的方向滑动检测 |
| 带有视觉反馈和阻力效果的下拉刷新 |
快速使用示例:
tsx
// Swipe to dismiss
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: () => dismiss(),
threshold: 50,
});
// Pull to refresh
const { containerRef, pullDistance, isRefreshing, handlers } =
usePullToRefresh(async () => await refetchData());Mobile Performance
移动端性能
Image Optimization
图片优化
tsx
import Image from 'next/image';
// Responsive images with proper sizing
<Image
src="/hero.jpg"
alt="Hero"
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority // For above-the-fold images
className="object-cover"
/>
// Lazy load below-fold images
<Image
src="/feature.jpg"
alt="Feature"
width={400}
height={300}
loading="lazy"
/>tsx
import Image from 'next/image';
// Responsive images with proper sizing
<Image
src="/hero.jpg"
alt="Hero"
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority // For above-the-fold images
className="object-cover"
/>
// Lazy load below-fold images
<Image
src="/feature.jpg"
alt="Feature"
width={400}
height={300}
loading="lazy"
/>Reduce Bundle Size
减小包体积
tsx
// Dynamic imports for heavy components
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Skip server render for client-only
});
// Lazy load below-fold sections
const Comments = dynamic(() => import('@/components/Comments'));tsx
// Dynamic imports for heavy components
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // Skip server render for client-only
});
// Lazy load below-fold sections
const Comments = dynamic(() => import('@/components/Comments'));Skeleton Screens (Not Spinners)
骨架屏(而非加载动画)
tsx
// Skeleton that matches final content layout
function MeetingCardSkeleton() {
return (
<div className="p-4 bg-leather-800 rounded-lg animate-pulse">
<div className="h-4 bg-leather-700 rounded w-3/4 mb-2" />
<div className="h-3 bg-leather-700 rounded w-1/2 mb-4" />
<div className="flex gap-2">
<div className="h-6 w-16 bg-leather-700 rounded" />
<div className="h-6 w-16 bg-leather-700 rounded" />
</div>
</div>
);
}
// Usage
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => <MeetingCardSkeleton key={i} />)}
</div>
) : (
meetings.map(m => <MeetingCard key={m.id} meeting={m} />)
)}tsx
// Skeleton that matches final content layout
function MeetingCardSkeleton() {
return (
<div className="p-4 bg-leather-800 rounded-lg animate-pulse">
<div className="h-4 bg-leather-700 rounded w-3/4 mb-2" />
<div className="h-3 bg-leather-700 rounded w-1/2 mb-4" />
<div className="flex gap-2">
<div className="h-6 w-16 bg-leather-700 rounded" />
<div className="h-6 w-16 bg-leather-700 rounded" />
</div>
</div>
);
}
// Usage
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => <MeetingCardSkeleton key={i} />)}
</div>
) : (
meetings.map(m => <MeetingCard key={m.id} meeting={m} />)
)}Responsive Patterns
响应式模式
Tailwind Breakpoint Strategy
Tailwind断点策略
sm: 640px - Large phones (landscape)
md: 768px - Tablets
lg: 1024px - Small laptops
xl: 1280px - Desktops
2xl: 1536px - Large screenstsx
// Mobile: stack, Tablet+: side-by-side
<div className="flex flex-col md:flex-row gap-4">
<aside className="w-full md:w-64">Sidebar</aside>
<main className="flex-1">Content</main>
</div>
// Mobile: bottom nav, Desktop: sidebar
<nav className="md:hidden fixed bottom-0 left-0 right-0">
<BottomNav />
</nav>
<aside className="hidden md:block w-64">
<SidebarNav />
</aside>sm: 640px - 大屏手机(横屏)
md: 768px - 平板
lg: 1024px - 小型笔记本
xl: 1280px - 桌面端
2xl: 1536px - 大屏显示器tsx
// Mobile: stack, Tablet+: side-by-side
<div className="flex flex-col md:flex-row gap-4">
<aside className="w-full md:w-64">Sidebar</aside>
<main className="flex-1">Content</main>
</div>
// Mobile: bottom nav, Desktop: sidebar
<nav className="md:hidden fixed bottom-0 left-0 right-0">
<BottomNav />
</nav>
<aside className="hidden md:block w-64">
<SidebarNav />
</aside>Container Queries (CSS-only Responsive Components)
容器查询(纯CSS响应式组件)
css
/* Component responds to its container, not viewport */
@container (min-width: 400px) {
.card { flex-direction: row; }
}tsx
<div className="@container">
<div className="flex flex-col @md:flex-row">
{/* Responds to parent container width */}
</div>
</div>css
/* Component responds to its container, not viewport */
@container (min-width: 400px) {
.card { flex-direction: row; }
}tsx
<div className="@container">
<div className="flex flex-col @md:flex-row">
{/* Responds to parent container width */}
</div>
</div>Testing on Real Devices
真机测试
Chrome DevTools Mobile Emulation
Chrome DevTools移动端模拟
- Open DevTools (F12)
- Toggle device toolbar (Ctrl+Shift+M)
- Select device or set custom dimensions
- Throttle network/CPU for realistic performance
- 打开DevTools(F12)
- 切换设备工具栏(Ctrl+Shift+M)
- 选择设备或设置自定义尺寸
- 限制网络/CPU速度以模拟真实性能
Must-Test Scenarios
必测场景
- Content doesn't get cut off by notch/home indicator
- Touch targets are at least 44×44px
- Scrolling is smooth (no jank)
- Bottom nav doesn't block content
- Forms work with virtual keyboard visible
- Landscape orientation works
- Pull-to-refresh doesn't fight with scroll
- 内容不会被刘海屏/Home指示器截断
- 触控目标尺寸至少为44×44px
- 滚动流畅(无卡顿)
- 底部导航不会遮挡内容
- 虚拟键盘弹出时表单可正常使用
- 横屏模式正常工作
- 下拉刷新不会与滚动冲突
BrowserStack/Real Device Testing
BrowserStack/真机测试
bash
undefinedbash
undefinedExpose local dev server to internet
Expose local dev server to internet
npx localtunnel --port 3000
npx localtunnel --port 3000
or
or
ngrok http 3000
undefinedngrok http 3000
undefinedQuick Reference
快速参考
| Issue | Solution |
|---|---|
| Content cut off at bottom | Use |
| Notch overlaps content | Add |
| Touch targets too small | Min 44×44px |
| Scroll locked | Check |
| Keyboard covers input | Use |
| Janky scrolling | Use |
| Double-tap zoom | Add |
| 问题 | 解决方案 |
|---|---|
| 内容底部被截断 | 使用 |
| 刘海屏遮挡内容 | 添加 |
| 触控目标过小 | 最小尺寸设置为44×44px |
| 滚动被锁定 | 检查body是否设置了 |
| 键盘遮挡输入框 | 使用 |
| 滚动卡顿 | 使用 |
| 双击缩放 | 添加 |
References
参考资料
See for detailed guides:
/references/- - Virtual keyboard and form UX
keyboard-handling.md - - Touch-friendly animations
animations.md - - Mobile a11y requirements
accessibility.md
查看获取详细指南:
/references/- - 虚拟键盘与表单UX
keyboard-handling.md - - 触控友好型动画
animations.md - - 移动端无障碍要求
accessibility.md