mobile-ux-optimizer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Mobile-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 (
    100vh
    problems, safe areas, notches)
  • 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
    react-native
    or
    swift-executor
    skills
  • 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
    pwa-expert
    skill
适用该技能的场景:
  • 视口问题(
    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

dvh
解决方案

Mobile browsers have dynamic toolbars.
100vh
includes the URL bar, causing content to be cut off.
css
/* ❌ 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;
}
移动端浏览器拥有动态工具栏。
100vh
会包含地址栏,导致内容被截断。
css
/* ❌ 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 in
references/gestures.md
HookPurpose
useSwipe()
Directional swipe detection with configurable threshold
usePullToRefresh()
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用途
useSwipe()
可配置阈值的方向滑动检测
usePullToRefresh()
带有视觉反馈和阻力效果的下拉刷新
快速使用示例:
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 screens
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>
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移动端模拟

  1. Open DevTools (F12)
  2. Toggle device toolbar (Ctrl+Shift+M)
  3. Select device or set custom dimensions
  4. Throttle network/CPU for realistic performance
  1. 打开DevTools(F12)
  2. 切换设备工具栏(Ctrl+Shift+M)
  3. 选择设备或设置自定义尺寸
  4. 限制网络/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
undefined
bash
undefined

Expose local dev server to internet

Expose local dev server to internet

npx localtunnel --port 3000
npx localtunnel --port 3000

or

or

ngrok http 3000
undefined
ngrok http 3000
undefined

Quick Reference

快速参考

IssueSolution
Content cut off at bottomUse
100dvh
instead of
100vh
Notch overlaps contentAdd
pt-safe
/
pb-safe
Touch targets too smallMin 44×44px
Scroll lockedCheck
overflow: hidden
on body
Keyboard covers inputUse
visualViewport
API
Janky scrollingUse
will-change: transform
Double-tap zoomAdd
touch-action: manipulation
问题解决方案
内容底部被截断使用
100dvh
替代
100vh
刘海屏遮挡内容添加
pt-safe
/
pb-safe
触控目标过小最小尺寸设置为44×44px
滚动被锁定检查body是否设置了
overflow: hidden
键盘遮挡输入框使用
visualViewport
API
滚动卡顿使用
will-change: transform
双击缩放添加
touch-action: manipulation

References

参考资料

See
/references/
for detailed guides:
  • keyboard-handling.md
    - Virtual keyboard and form UX
  • animations.md
    - Touch-friendly animations
  • accessibility.md
    - Mobile a11y requirements
查看
/references/
获取详细指南:
  • keyboard-handling.md
    - 虚拟键盘与表单UX
  • animations.md
    - 触控友好型动画
  • accessibility.md
    - 移动端无障碍要求