重要前提
安装AI Skills的关键前提是:必须科学上网,且开启TUN模式,这一点至关重要,直接决定安装能否顺利完成,在此郑重提醒三遍:科学上网,科学上网,科学上网。查看完整安装教程 →
mobile-design by manutej/luxor-claude-marketplace
npx skills add https://github.com/manutej/luxor-claude-marketplace --skill mobile-design在以下场景中使用此技能:
此技能帮助您创建在智能手机和平板电脑上感觉原生、性能良好并能取悦用户的移动体验。
移动优先设计从最小的屏幕开始,逐步增强以适应更大的设备:
为何采用移动优先?
移动优先 vs 桌面优先:
/* Mobile-First Approach (Recommended) */
/* Base styles for mobile */
.container {
padding: 16px;
font-size: 14px;
}
/* Tablet enhancements */
@media (min-width: 768px) {
.container {
padding: 24px;
font-size: 16px;
}
}
/* Desktop enhancements */
@media (min-width: 1024px) {
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
}
/* Desktop-First Approach (Not Recommended) */
/* Base styles for desktop */
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
font-size: 16px;
}
/* Tablet overrides */
@media (max-width: 1023px) {
.container {
padding: 24px;
}
}
/* Mobile overrides */
@media (max-width: 767px) {
.container {
padding: 16px;
font-size: 14px;
}
}
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
最小触摸目标尺寸:
触摸目标间距:
拇指区域:
移动屏幕有三个符合人体工程学的区域:
设计影响:
将主要操作放在轻松区域(底部中心)
将破坏性操作放在困难区域(顶部角落)
导航通常在顶部或底部,绝不在中间
同时考虑左手和右手用户
// React Native: Bottom-aligned primary action (easy zone) <View style={styles.container}> <ScrollView style={styles.content}> {/* Main content */} </ScrollView>
<View style={styles.bottomActions}> <TouchableOpacity style={styles.primaryButton}> <Text>Continue</Text> </TouchableOpacity> </View> </View>const styles = StyleSheet.create({ container: { flex: 1, }, content: { flex: 1, }, bottomActions: { padding: 16, paddingBottom: 32, // Extra padding for iPhone home indicator backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e0e0e0', }, primaryButton: { height: 56, // Optimal touch target borderRadius: 28, backgroundColor: '#007AFF', justifyContent: 'center', alignItems: 'center', }, });
常见移动端断点:
/* Extra small devices (phones, 320px - 479px) */
@media (min-width: 320px) { }
/* Small devices (large phones, 480px - 767px) */
@media (min-width: 480px) { }
/* Medium devices (tablets, 768px - 1023px) */
@media (min-width: 768px) { }
/* Large devices (small laptops, 1024px - 1279px) */
@media (min-width: 1024px) { }
/* Extra large devices (desktops, 1280px and up) */
@media (min-width: 1280px) { }
视口 Meta 标签:
<!-- Responsive viewport (required for mobile) -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes">
<!-- PWA with standalone mode -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
安全区域(iPhone X 及之后):
/* Account for notch and home indicator */
.header {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.footer {
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
单次点击:
按钮、链接、列表项上的主要操作
应提供即时视觉反馈(0-100 毫秒延迟)
最小尺寸:48×48 像素
// React: Tap with visual feedback import { useState } from 'react';
function TapButton({ onPress, children }) { const [isPressed, setIsPressed] = useState(false);
return (
<button
className={tap-button ${isPressed ? 'pressed' : ''}}
onTouchStart={() => setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
onTouchCancel={() => setIsPressed(false)}
onClick={onPress}
>
{children}
</button>
);
}
// CSS .tap-button { padding: 16px 24px; background: #007AFF; color: white; border: none; border-radius: 8px; font-size: 16px; min-height: 48px; transition: transform 0.1s, background 0.1s; -webkit-tap-highlight-color: transparent; }
.tap-button.pressed { transform: scale(0.96); background: #0051D5; }
.tap-button:active { transform: scale(0.96); }
双击:
iOS 双击缩放预防:
/* Prevent double-tap zoom while allowing pinch zoom */
touch-action: manipulation;
水平滑动:
在屏幕/页面之间导航
显示操作(滑动删除、滑动归档)
关闭卡片/模态框
切换标签页
// React: Swipeable list item import { useState } from 'react';
function SwipeableListItem({ children, onDelete, onArchive }) { const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); const [translateX, setTranslateX] = useState(0);
const minSwipeDistance = 50;
const onTouchStart = (e) => { setTouchEnd(null); setTouchStart(e.targetTouches[0].clientX); };
const onTouchMove = (e) => { setTouchEnd(e.targetTouches[0].clientX); const distance = touchStart - e.targetTouches[0].clientX; setTranslateX(-distance); };
const onTouchEnd = () => { if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
setTranslateX(-80); // Show actions
} else if (isRightSwipe) {
setTranslateX(0); // Reset
} else {
setTranslateX(0); // Snap back
}
};
return ( <div className="swipeable-item-container"> <div className="swipe-actions"> <button onClick={onArchive} className="archive-btn">Archive</button> <button onClick={onDelete} className="delete-btn">Delete</button> </div>
<div
className="swipeable-item"
style={{ transform: `translateX(${translateX}px)` }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{children}
</div>
</div>
); }
垂直滑动:
下拉刷新(从顶部向下滑动)
滚动内容
关闭底部表单/模态框(向下滑动)
// Pull to Refresh function PullToRefresh({ onRefresh, children }) { const [pulling, setPulling] = useState(false); const [pullDistance, setPullDistance] = useState(0); const threshold = 80;
const handleTouchStart = (e) => { if (window.scrollY === 0) { setPulling(true); } };
const handleTouchMove = (e) => { if (pulling && window.scrollY === 0) { const distance = e.touches[0].clientY - e.touches[0].target.getBoundingClientRect().top; setPullDistance(Math.min(distance, threshold * 1.5)); } };
const handleTouchEnd = () => { if (pullDistance >= threshold) { onRefresh(); } setPulling(false); setPullDistance(0); };
return ( <div onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} > {pullDistance > 0 && ( <div className="pull-indicator" style={{ height: pullDistance }}> {pullDistance >= threshold ? '↻ Release to refresh' : '↓ Pull to refresh'} </div> )} {children} </div> ); }
用于:
图片库
地图
PDF 查看器
任何可缩放的内容
// React: Pinch to Zoom function PinchZoomImage({ src, alt }) { const [scale, setScale] = useState(1); const [lastScale, setLastScale] = useState(1);
const handleTouchMove = (e) => { if (e.touches.length === 2) { e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
if (lastDistance) {
const newScale = lastScale * (distance / lastDistance);
setScale(Math.max(1, Math.min(newScale, 4))); // Limit 1x to 4x
}
lastDistance = distance;
}
};
const handleTouchEnd = () => { setLastScale(scale); lastDistance = null; };
let lastDistance = null;
return (
<div
className="pinch-zoom-container"
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<img
src={src}
alt={alt}
style={{
transform: scale(${scale}),
transition: lastDistance ? 'none' : 'transform 0.2s',
}}
/>
</div>
);
}
用于:
上下文菜单
项目选择模式
拖放启动
附加选项
// React: Long Press Handler function useLongPress(callback, ms = 500) { const [startLongPress, setStartLongPress] = useState(false);
useEffect(() => { let timerId; if (startLongPress) { timerId = setTimeout(callback, ms); } else { clearTimeout(timerId); }
return () => {
clearTimeout(timerId);
};
}, [startLongPress, callback, ms]);
return { onTouchStart: () => setStartLongPress(true), onTouchEnd: () => setStartLongPress(false), onTouchMove: () => setStartLongPress(false), }; }
// Usage function LongPressItem({ item }) { const longPressProps = useLongPress(() => { console.log('Long press detected!'); // Show context menu }, 500);
return ( <div {...longPressProps} className="long-press-item"> {item.name} </div> ); }
// React Native: Drag and Drop
import { PanResponder, Animated } from 'react-native';
function DraggableCard({ children }) {
const pan = useRef(new Animated.ValueXY()).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.setOffset({
x: pan.x._value,
y: pan.y._value,
});
},
onPanResponderMove: Animated.event(
[null, { dx: pan.x, dy: pan.y }],
{ useNativeDriver: false }
),
onPanResponderRelease: () => {
pan.flattenOffset();
Animated.spring(pan, {
toValue: { x: 0, y: 0 },
useNativeDriver: true,
}).start();
},
})
).current;
return (
<Animated.View
{...panResponder.panHandlers}
style={{
transform: [{ translateX: pan.x }, { translateY: pan.y }],
}}
>
{children}
</Animated.View>
);
}
底部标签栏(iOS 标准,Android 常见):
// React Native: Bottom Tab Navigation
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/Ionicons';
const Tab = createBottomTabNavigator();
function AppNavigator() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName;
if (route.name === 'Home') {
iconName = focused ? 'home' : 'home-outline';
} else if (route.name === 'Search') {
iconName = focused ? 'search' : 'search-outline';
} else if (route.name === 'Profile') {
iconName = focused ? 'person' : 'person-outline';
}
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
tabBarStyle: {
height: 88, // Account for safe area
paddingBottom: 34, // iPhone home indicator
},
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
最佳实践:
// React Native: Drawer Navigation
import { createDrawerNavigator } from '@react-navigation/drawer';
const Drawer = createDrawerNavigator();
function DrawerNavigator() {
return (
<Drawer.Navigator
screenOptions={{
drawerPosition: 'left',
drawerType: 'slide',
drawerStyle: {
width: 280,
},
headerShown: true,
}}
>
<Drawer.Screen
name="Home"
component={HomeScreen}
options={{
drawerIcon: ({ color, size }) => (
<Icon name="home-outline" color={color} size={size} />
),
}}
/>
<Drawer.Screen
name="Settings"
component={SettingsScreen}
options={{
drawerIcon: ({ color, size }) => (
<Icon name="settings-outline" color={color} size={size} />
),
}}
/>
</Drawer.Navigator>
);
}
何时使用:
避免使用的情况:
底部表单(Material Design):
// React: Bottom Sheet
function BottomSheet({ isOpen, onClose, children }) {
const [startY, setStartY] = useState(0);
const [currentY, setCurrentY] = useState(0);
const handleTouchStart = (e) => {
setStartY(e.touches[0].clientY);
};
const handleTouchMove = (e) => {
const delta = e.touches[0].clientY - startY;
if (delta > 0) { // Only allow downward drag
setCurrentY(delta);
}
};
const handleTouchEnd = () => {
if (currentY > 100) { // Threshold for closing
onClose();
}
setCurrentY(0);
};
if (!isOpen) return null;
return (
<>
<div className="bottom-sheet-backdrop" onClick={onClose} />
<div
className="bottom-sheet"
style={{ transform: `translateY(${currentY}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" />
<div className="bottom-sheet-content">
{children}
</div>
</div>
</>
);
}
// CSS
.bottom-sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 20px 20px 0 0;
padding: 16px;
max-height: 80vh;
z-index: 1000;
transition: transform 0.3s;
}
.bottom-sheet-handle {
width: 40px;
height: 4px;
background: #D1D1D6;
border-radius: 2px;
margin: 8px auto 16px;
}
全屏模态框:
// iOS-style modal with slide-up animation
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal-header">
<button onClick={onClose} className="modal-close">
Done
</button>
</div>
<div className="modal-content">
{children}
</div>
</div>
</div>
);
}
// CSS
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.3s;
}
.modal-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: white;
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
// React Navigation: Stack Navigator
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
function StackNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: '#007AFF',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
cardStyleInterpolator: ({ current, layouts }) => {
return {
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [layouts.screen.width, 0],
}),
},
],
},
};
},
}}
>
<Stack.Screen name="List" component={ListScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
<Stack.Screen name="Edit" component={EditScreen} />
</Stack.Navigator>
);
}
Material Design 卡片:
function Card({ image, title, subtitle, description, actions }) {
return (
<div className="card">
{image && (
<div className="card-media">
<img src={image} alt={title} />
</div>
)}
<div className="card-content">
<h3 className="card-title">{title}</h3>
{subtitle && <p className="card-subtitle">{subtitle}</p>}
<p className="card-description">{description}</p>
</div>
{actions && (
<div className="card-actions">
{actions}
</div>
)}
</div>
);
}
// CSS
.card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
}
.card-media img {
width: 100%;
height: 200px;
object-fit: cover;
}
.card-content {
padding: 16px;
}
.card-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
}
.card-subtitle {
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
}
.card-description {
font-size: 14px;
line-height: 1.5;
color: #333;
}
.card-actions {
padding: 8px 16px 16px;
display: flex;
gap: 8px;
}
iOS 风格列表:
function IOSList({ items, onItemPress }) {
return (
<div className="ios-list">
{items.map((item, index) => (
<div
key={item.id}
className="ios-list-item"
onClick={() => onItemPress(item)}
>
{item.icon && (
<div className="ios-list-icon">{item.icon}</div>
)}
<div className="ios-list-content">
<div className="ios-list-title">{item.title}</div>
{item.subtitle && (
<div className="ios-list-subtitle">{item.subtitle}</div>
)}
</div>
{item.badge && (
<div className="ios-list-badge">{item.badge}</div>
)}
<div className="ios-list-chevron">›</div>
</div>
))}
</div>
);
}
// CSS
.ios-list {
background: white;
border-radius: 12px;
overflow: hidden;
}
.ios-list-item {
display: flex;
align-items: center;
padding: 12px 16px;
min-height: 56px;
border-bottom: 0.5px solid #E5E5EA;
-webkit-tap-highlight-color: transparent;
}
.ios-list-item:active {
background: #F2F2F7;
}
.ios-list-item:last-child {
border-bottom: none;
}
.ios-list-icon {
width: 32px;
height: 32px;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.ios-list-content {
flex: 1;
}
.ios-list-title {
font-size: 17px;
color: #000;
}
.ios-list-subtitle {
font-size: 15px;
color: #8E8E93;
margin-top: 2px;
}
.ios-list-badge {
background: #FF3B30;
color: white;
font-size: 13px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
margin-right: 8px;
}
.ios-list-chevron {
font-size: 24px;
color: #C7C7CC;
}
移动端优化的表单:
function MobileForm() {
return (
<form className="mobile-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
id="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="(555) 123-4567"
/>
</div>
<div className="form-group">
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
inputMode="decimal"
placeholder="0.00"
/>
</div>
<button type="submit" className="submit-button">
Submit
</button>
</form>
);
}
// CSS
.mobile-form {
padding: 16px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.form-group input {
width: 100%;
height: 56px;
padding: 16px;
font-size: 16px; /* Prevents zoom on iOS */
border: 2px solid #E5E5EA;
border-radius: 12px;
background: white;
-webkit-appearance: none;
}
.form-group input:focus {
outline: none;
border-color: #007AFF;
}
.submit-button {
width: 100%;
height: 56px;
background: #007AFF;
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
}
移动端键盘的输入类型:
<!-- Email keyboard -->
<input type="email" inputmode="email">
<!-- Numeric keyboard -->
<input type="number" inputmode="numeric">
<!-- Decimal keyboard (includes . and ,) -->
<input type="number" inputmode="decimal">
<!-- Telephone keyboard -->
<input type="tel" inputmode="tel">
<!-- URL keyboard (includes .com, /, etc.) -->
<input type="url" inputmode="url">
<!-- Search keyboard (includes search button) -->
<input type="search" inputmode="search">
// iOS-style Action Sheet
function ActionSheet({ isOpen, onClose, title, options }) {
if (!isOpen) return null;
return (
<>
<div className="action-sheet-backdrop" onClick={onClose} />
<div className="action-sheet">
{title && <div className="action-sheet-title">{title}</div>}
<div className="action-sheet-options">
{options.map((option, index) => (
<button
key={index}
className={`action-sheet-option ${option.destructive ? 'destructive' : ''}`}
onClick={() => {
option.onPress();
onClose();
}}
>
{option.icon && <span className="option-icon">{option.icon}</span>}
{option.label}
</button>
))}
</div>
<button className="action-sheet-cancel" onClick={onClose}>
Cancel
</button>
</div>
</>
);
}
// CSS
.action-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: transparent;
z-index: 1001;
padding: 8px;
animation: slideUp 0.3s;
}
.action-sheet-title {
background: rgba(255, 255, 255, 0.95);
padding: 16px;
text-align: center;
border-radius: 14px 14px 0 0;
font-size: 13px;
color: #8E8E93;
}
.action-sheet-options {
background: rgba(255, 255, 255, 0.95);
border-radius: 14px;
overflow: hidden;
margin-bottom: 8px;
}
.action-sheet-option {
width: 100%;
padding: 16px;
background: transparent;
border: none;
border-bottom: 0.5px solid #E5E5EA;
font-size: 20px;
color: #007AFF;
-webkit-tap-highlight-color: transparent;
}
.action-sheet-option:active {
background: rgba(0, 0, 0, 0.05);
}
.action-sheet-option.destructive {
color: #FF3B30;
}
.action-sheet-cancel {
width: 100%;
padding: 16px;
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: 14px;
font-size: 20px;
font-weight: 600;
color: #007AFF;
}
导航栏:
标签栏:
排版:
颜色:
间距:
最小边距:16pt
标准间距:8pt、16pt、24pt、32pt
组件内边距:水平 16pt,垂直 12pt
// SwiftUI: iOS Navigation struct ContentView: View { var body: some View { NavigationView { List(items) { item in NavigationLink(destination: DetailView(item: item)) { HStack { Image(systemName: item.icon) .foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Items")
.navigationBarTitleDisplayMode(.large)
}
}
}
应用栏:
底部导航:
FAB(浮动操作按钮):
Use this skill when working on:
This skill helps you create mobile experiences that feel native, perform well, and delight users on smartphones and tablets.
Mobile-first design starts with the smallest screen and progressively enhances for larger devices:
Why Mobile-First?
Mobile-First vs Desktop-First:
/* Mobile-First Approach (Recommended) */
/* Base styles for mobile */
.container {
padding: 16px;
font-size: 14px;
}
/* Tablet enhancements */
@media (min-width: 768px) {
.container {
padding: 24px;
font-size: 16px;
}
}
/* Desktop enhancements */
@media (min-width: 1024px) {
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
}
/* Desktop-First Approach (Not Recommended) */
/* Base styles for desktop */
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
font-size: 16px;
}
/* Tablet overrides */
@media (max-width: 1023px) {
.container {
padding: 24px;
}
}
/* Mobile overrides */
@media (max-width: 767px) {
.container {
padding: 16px;
font-size: 14px;
}
}
Minimum Touch Target Sizes:
Touch Target Spacing:
Thumb Zones:
Mobile screens have three ergonomic zones:
Design Implications:
Place primary actions in the easy zone (bottom center)
Put destructive actions in difficult zones (top corners)
Navigation typically at top or bottom, never middle
Consider both left-handed and right-handed users
// React Native: Bottom-aligned primary action (easy zone) <View style={styles.container}> <ScrollView style={styles.content}> {/* Main content */} </ScrollView>
<View style={styles.bottomActions}> <TouchableOpacity style={styles.primaryButton}> <Text>Continue</Text> </TouchableOpacity> </View> </View>const styles = StyleSheet.create({ container: { flex: 1, }, content: { flex: 1, }, bottomActions: { padding: 16, paddingBottom: 32, // Extra padding for iPhone home indicator backgroundColor: '#fff', borderTopWidth: 1, borderTopColor: '#e0e0e0', }, primaryButton: { height: 56, // Optimal touch target borderRadius: 28, backgroundColor: '#007AFF', justifyContent: 'center', alignItems: 'center', }, });
Common Mobile Breakpoints:
/* Extra small devices (phones, 320px - 479px) */
@media (min-width: 320px) { }
/* Small devices (large phones, 480px - 767px) */
@media (min-width: 480px) { }
/* Medium devices (tablets, 768px - 1023px) */
@media (min-width: 768px) { }
/* Large devices (small laptops, 1024px - 1279px) */
@media (min-width: 1024px) { }
/* Extra large devices (desktops, 1280px and up) */
@media (min-width: 1280px) { }
Viewport Meta Tag:
<!-- Responsive viewport (required for mobile) -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, user-scalable=yes">
<!-- PWA with standalone mode -->
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
Safe Areas (iPhone X and later):
/* Account for notch and home indicator */
.header {
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
.footer {
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
Single Tap:
Primary action on buttons, links, list items
Should provide immediate visual feedback (0-100ms delay)
Minimum size: 48×48 pixels
// React: Tap with visual feedback import { useState } from 'react';
function TapButton({ onPress, children }) { const [isPressed, setIsPressed] = useState(false);
return (
<button
className={tap-button ${isPressed ? 'pressed' : ''}}
onTouchStart={() => setIsPressed(true)}
onTouchEnd={() => setIsPressed(false)}
onTouchCancel={() => setIsPressed(false)}
onClick={onPress}
>
{children}
</button>
);
}
// CSS .tap-button { padding: 16px 24px; background: #007AFF; color: white; border: none; border-radius: 8px; font-size: 16px; min-height: 48px; transition: transform 0.1s, background 0.1s; -webkit-tap-highlight-color: transparent; }
.tap-button.pressed { transform: scale(0.96); background: #0051D5; }
.tap-button:active { transform: scale(0.96); }
Double Tap:
iOS Double-Tap Zoom Prevention:
/* Prevent double-tap zoom while allowing pinch zoom */
touch-action: manipulation;
Horizontal Swipe:
Navigate between screens/pages
Reveal actions (swipe-to-delete, swipe-to-archive)
Dismiss cards/modals
Switch tabs
// React: Swipeable list item import { useState } from 'react';
function SwipeableListItem({ children, onDelete, onArchive }) { const [touchStart, setTouchStart] = useState(null); const [touchEnd, setTouchEnd] = useState(null); const [translateX, setTranslateX] = useState(0);
const minSwipeDistance = 50;
const onTouchStart = (e) => { setTouchEnd(null); setTouchStart(e.targetTouches[0].clientX); };
const onTouchMove = (e) => { setTouchEnd(e.targetTouches[0].clientX); const distance = touchStart - e.targetTouches[0].clientX; setTranslateX(-distance); };
const onTouchEnd = () => { if (!touchStart || !touchEnd) return;
const distance = touchStart - touchEnd;
const isLeftSwipe = distance > minSwipeDistance;
const isRightSwipe = distance < -minSwipeDistance;
if (isLeftSwipe) {
setTranslateX(-80); // Show actions
} else if (isRightSwipe) {
setTranslateX(0); // Reset
} else {
setTranslateX(0); // Snap back
}
};
return ( <div className="swipeable-item-container"> <div className="swipe-actions"> <button onClick={onArchive} className="archive-btn">Archive</button> <button onClick={onDelete} className="delete-btn">Delete</button> </div>
<div
className="swipeable-item"
style={{ transform: `translateX(${translateX}px)` }}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
>
{children}
</div>
</div>
Vertical Swipe:
Pull to refresh (downward swipe from top)
Scroll content
Dismiss bottom sheets/modals (downward swipe)
// Pull to Refresh function PullToRefresh({ onRefresh, children }) { const [pulling, setPulling] = useState(false); const [pullDistance, setPullDistance] = useState(0); const threshold = 80;
const handleTouchStart = (e) => { if (window.scrollY === 0) { setPulling(true); } };
const handleTouchMove = (e) => { if (pulling && window.scrollY === 0) { const distance = e.touches[0].clientY - e.touches[0].target.getBoundingClientRect().top; setPullDistance(Math.min(distance, threshold * 1.5)); } };
const handleTouchEnd = () => { if (pullDistance >= threshold) { onRefresh(); } setPulling(false); setPullDistance(0); };
return ( <div onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} > {pullDistance > 0 && ( <div className="pull-indicator" style={{ height: pullDistance }}> {pullDistance >= threshold ? '↻ Release to refresh' : '↓ Pull to refresh'} </div> )} {children} </div> ); }
Used for:
Image galleries
Maps
PDF viewers
Any zoomable content
// React: Pinch to Zoom function PinchZoomImage({ src, alt }) { const [scale, setScale] = useState(1); const [lastScale, setLastScale] = useState(1);
const handleTouchMove = (e) => { if (e.touches.length === 2) { e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY
);
if (lastDistance) {
const newScale = lastScale * (distance / lastDistance);
setScale(Math.max(1, Math.min(newScale, 4))); // Limit 1x to 4x
}
lastDistance = distance;
}
};
const handleTouchEnd = () => { setLastScale(scale); lastDistance = null; };
let lastDistance = null;
return (
<div
className="pinch-zoom-container"
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<img
src={src}
alt={alt}
style={{
transform: scale(${scale}),
transition: lastDistance ? 'none' : 'transform 0.2s',
}}
/>
</div>
);
}
Used for:
Context menus
Item selection mode
Drag-and-drop initiation
Additional options
// React: Long Press Handler function useLongPress(callback, ms = 500) { const [startLongPress, setStartLongPress] = useState(false);
useEffect(() => { let timerId; if (startLongPress) { timerId = setTimeout(callback, ms); } else { clearTimeout(timerId); }
return () => {
clearTimeout(timerId);
};
}, [startLongPress, callback, ms]);
return { onTouchStart: () => setStartLongPress(true), onTouchEnd: () => setStartLongPress(false), onTouchMove: () => setStartLongPress(false), }; }
// Usage function LongPressItem({ item }) { const longPressProps = useLongPress(() => { console.log('Long press detected!'); // Show context menu }, 500);
return ( <div {...longPressProps} className="long-press-item"> {item.name} </div> ); }
// React Native: Drag and Drop
import { PanResponder, Animated } from 'react-native';
function DraggableCard({ children }) {
const pan = useRef(new Animated.ValueXY()).current;
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onPanResponderGrant: () => {
pan.setOffset({
x: pan.x._value,
y: pan.y._value,
});
},
onPanResponderMove: Animated.event(
[null, { dx: pan.x, dy: pan.y }],
{ useNativeDriver: false }
),
onPanResponderRelease: () => {
pan.flattenOffset();
Animated.spring(pan, {
toValue: { x: 0, y: 0 },
useNativeDriver: true,
}).start();
},
})
).current;
return (
<Animated.View
{...panResponder.panHandlers}
style={{
transform: [{ translateX: pan.x }, { translateY: pan.y }],
}}
>
{children}
</Animated.View>
);
}
Bottom Tab Bar (iOS standard, Android common):
// React Native: Bottom Tab Navigation
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import Icon from 'react-native-vector-icons/Ionicons';
const Tab = createBottomTabNavigator();
function AppNavigator() {
return (
<Tab.Navigator
screenOptions={({ route }) => ({
tabBarIcon: ({ focused, color, size }) => {
let iconName;
if (route.name === 'Home') {
iconName = focused ? 'home' : 'home-outline';
} else if (route.name === 'Search') {
iconName = focused ? 'search' : 'search-outline';
} else if (route.name === 'Profile') {
iconName = focused ? 'person' : 'person-outline';
}
return <Icon name={iconName} size={size} color={color} />;
},
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
tabBarStyle: {
height: 88, // Account for safe area
paddingBottom: 34, // iPhone home indicator
},
})}
>
<Tab.Screen name="Home" component={HomeScreen} />
<Tab.Screen name="Search" component={SearchScreen} />
<Tab.Screen name="Profile" component={ProfileScreen} />
</Tab.Navigator>
);
}
Best Practices:
// React Native: Drawer Navigation
import { createDrawerNavigator } from '@react-navigation/drawer';
const Drawer = createDrawerNavigator();
function DrawerNavigator() {
return (
<Drawer.Navigator
screenOptions={{
drawerPosition: 'left',
drawerType: 'slide',
drawerStyle: {
width: 280,
},
headerShown: true,
}}
>
<Drawer.Screen
name="Home"
component={HomeScreen}
options={{
drawerIcon: ({ color, size }) => (
<Icon name="home-outline" color={color} size={size} />
),
}}
/>
<Drawer.Screen
name="Settings"
component={SettingsScreen}
options={{
drawerIcon: ({ color, size }) => (
<Icon name="settings-outline" color={color} size={size} />
),
}}
/>
</Drawer.Navigator>
);
}
When to Use:
Avoid When:
Bottom Sheet (Material Design):
// React: Bottom Sheet
function BottomSheet({ isOpen, onClose, children }) {
const [startY, setStartY] = useState(0);
const [currentY, setCurrentY] = useState(0);
const handleTouchStart = (e) => {
setStartY(e.touches[0].clientY);
};
const handleTouchMove = (e) => {
const delta = e.touches[0].clientY - startY;
if (delta > 0) { // Only allow downward drag
setCurrentY(delta);
}
};
const handleTouchEnd = () => {
if (currentY > 100) { // Threshold for closing
onClose();
}
setCurrentY(0);
};
if (!isOpen) return null;
return (
<>
<div className="bottom-sheet-backdrop" onClick={onClose} />
<div
className="bottom-sheet"
style={{ transform: `translateY(${currentY}px)` }}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" />
<div className="bottom-sheet-content">
{children}
</div>
</div>
</>
);
}
// CSS
.bottom-sheet-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}
.bottom-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
border-radius: 20px 20px 0 0;
padding: 16px;
max-height: 80vh;
z-index: 1000;
transition: transform 0.3s;
}
.bottom-sheet-handle {
width: 40px;
height: 4px;
background: #D1D1D6;
border-radius: 2px;
margin: 8px auto 16px;
}
Full-Screen Modal:
// iOS-style modal with slide-up animation
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal-container">
<div className="modal-header">
<button onClick={onClose} className="modal-close">
Done
</button>
</div>
<div className="modal-content">
{children}
</div>
</div>
</div>
);
}
// CSS
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
animation: fadeIn 0.3s;
}
.modal-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: white;
animation: slideUp 0.3s;
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
// React Navigation: Stack Navigator
import { createStackNavigator } from '@react-navigation/stack';
const Stack = createStackNavigator();
function StackNavigator() {
return (
<Stack.Navigator
screenOptions={{
headerStyle: {
backgroundColor: '#007AFF',
},
headerTintColor: '#fff',
headerTitleStyle: {
fontWeight: 'bold',
},
cardStyleInterpolator: ({ current, layouts }) => {
return {
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [layouts.screen.width, 0],
}),
},
],
},
};
},
}}
>
<Stack.Screen name="List" component={ListScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
<Stack.Screen name="Edit" component={EditScreen} />
</Stack.Navigator>
);
}
Material Design Card:
function Card({ image, title, subtitle, description, actions }) {
return (
<div className="card">
{image && (
<div className="card-media">
<img src={image} alt={title} />
</div>
)}
<div className="card-content">
<h3 className="card-title">{title}</h3>
{subtitle && <p className="card-subtitle">{subtitle}</p>}
<p className="card-description">{description}</p>
</div>
{actions && (
<div className="card-actions">
{actions}
</div>
)}
</div>
);
}
// CSS
.card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 16px;
}
.card-media img {
width: 100%;
height: 200px;
object-fit: cover;
}
.card-content {
padding: 16px;
}
.card-title {
font-size: 20px;
font-weight: 600;
margin: 0 0 8px 0;
}
.card-subtitle {
font-size: 14px;
color: #666;
margin: 0 0 8px 0;
}
.card-description {
font-size: 14px;
line-height: 1.5;
color: #333;
}
.card-actions {
padding: 8px 16px 16px;
display: flex;
gap: 8px;
}
iOS-Style List:
function IOSList({ items, onItemPress }) {
return (
<div className="ios-list">
{items.map((item, index) => (
<div
key={item.id}
className="ios-list-item"
onClick={() => onItemPress(item)}
>
{item.icon && (
<div className="ios-list-icon">{item.icon}</div>
)}
<div className="ios-list-content">
<div className="ios-list-title">{item.title}</div>
{item.subtitle && (
<div className="ios-list-subtitle">{item.subtitle}</div>
)}
</div>
{item.badge && (
<div className="ios-list-badge">{item.badge}</div>
)}
<div className="ios-list-chevron">›</div>
</div>
))}
</div>
);
}
// CSS
.ios-list {
background: white;
border-radius: 12px;
overflow: hidden;
}
.ios-list-item {
display: flex;
align-items: center;
padding: 12px 16px;
min-height: 56px;
border-bottom: 0.5px solid #E5E5EA;
-webkit-tap-highlight-color: transparent;
}
.ios-list-item:active {
background: #F2F2F7;
}
.ios-list-item:last-child {
border-bottom: none;
}
.ios-list-icon {
width: 32px;
height: 32px;
margin-right: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.ios-list-content {
flex: 1;
}
.ios-list-title {
font-size: 17px;
color: #000;
}
.ios-list-subtitle {
font-size: 15px;
color: #8E8E93;
margin-top: 2px;
}
.ios-list-badge {
background: #FF3B30;
color: white;
font-size: 13px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
margin-right: 8px;
}
.ios-list-chevron {
font-size: 24px;
color: #C7C7CC;
}
Mobile-Optimized Form:
function MobileForm() {
return (
<form className="mobile-form">
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
inputMode="email"
autoComplete="email"
placeholder="you@example.com"
/>
</div>
<div className="form-group">
<label htmlFor="phone">Phone</label>
<input
id="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="(555) 123-4567"
/>
</div>
<div className="form-group">
<label htmlFor="amount">Amount</label>
<input
id="amount"
type="number"
inputMode="decimal"
placeholder="0.00"
/>
</div>
<button type="submit" className="submit-button">
Submit
</button>
</form>
);
}
// CSS
.mobile-form {
padding: 16px;
}
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.form-group input {
width: 100%;
height: 56px;
padding: 16px;
font-size: 16px; /* Prevents zoom on iOS */
border: 2px solid #E5E5EA;
border-radius: 12px;
background: white;
-webkit-appearance: none;
}
.form-group input:focus {
outline: none;
border-color: #007AFF;
}
.submit-button {
width: 100%;
height: 56px;
background: #007AFF;
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
}
Input Types for Mobile Keyboards:
<!-- Email keyboard -->
<input type="email" inputmode="email">
<!-- Numeric keyboard -->
<input type="number" inputmode="numeric">
<!-- Decimal keyboard (includes . and ,) -->
<input type="number" inputmode="decimal">
<!-- Telephone keyboard -->
<input type="tel" inputmode="tel">
<!-- URL keyboard (includes .com, /, etc.) -->
<input type="url" inputmode="url">
<!-- Search keyboard (includes search button) -->
<input type="search" inputmode="search">
// iOS-style Action Sheet
function ActionSheet({ isOpen, onClose, title, options }) {
if (!isOpen) return null;
return (
<>
<div className="action-sheet-backdrop" onClick={onClose} />
<div className="action-sheet">
{title && <div className="action-sheet-title">{title}</div>}
<div className="action-sheet-options">
{options.map((option, index) => (
<button
key={index}
className={`action-sheet-option ${option.destructive ? 'destructive' : ''}`}
onClick={() => {
option.onPress();
onClose();
}}
>
{option.icon && <span className="option-icon">{option.icon}</span>}
{option.label}
</button>
))}
</div>
<button className="action-sheet-cancel" onClick={onClose}>
Cancel
</button>
</div>
</>
);
}
// CSS
.action-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: transparent;
z-index: 1001;
padding: 8px;
animation: slideUp 0.3s;
}
.action-sheet-title {
background: rgba(255, 255, 255, 0.95);
padding: 16px;
text-align: center;
border-radius: 14px 14px 0 0;
font-size: 13px;
color: #8E8E93;
}
.action-sheet-options {
background: rgba(255, 255, 255, 0.95);
border-radius: 14px;
overflow: hidden;
margin-bottom: 8px;
}
.action-sheet-option {
width: 100%;
padding: 16px;
background: transparent;
border: none;
border-bottom: 0.5px solid #E5E5EA;
font-size: 20px;
color: #007AFF;
-webkit-tap-highlight-color: transparent;
}
.action-sheet-option:active {
background: rgba(0, 0, 0, 0.05);
}
.action-sheet-option.destructive {
color: #FF3B30;
}
.action-sheet-cancel {
width: 100%;
padding: 16px;
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: 14px;
font-size: 20px;
font-weight: 600;
color: #007AFF;
}
Navigation Bar:
Tab Bar:
Typography:
Colors:
Spacing:
Minimum margins: 16pt
Standard spacing: 8pt, 16pt, 24pt, 32pt
Component padding: 16pt horizontal, 12pt vertical
// SwiftUI: iOS Navigation struct ContentView: View { var body: some View { NavigationView { List(items) { item in NavigationLink(destination: DetailView(item: item)) { HStack { Image(systemName: item.icon) .foregroundColor(.accentColor)
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.subtitle)
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.vertical, 8)
}
}
.navigationTitle("Items")
.navigationBarTitleDisplayMode(.large)
}
}
}
App Bar:
Bottom Navigation:
FAB (Floating Action Button):
Typography:
Elevation:
Spacing:
4dp grid system
Keylines: 16dp, 72dp from edges
Component spacing: 8dp, 16dp, 24dp
// Jetpack Compose: Material Design @Composable fun MaterialCard(item: Item) { Card( modifier = Modifier .fillMaxWidth() .padding(16.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Column(modifier = Modifier.padding(16.dp)) { Text( text = item.title, style = MaterialTheme.typography.headlineSmall )
Spacer(modifier = Modifier.height(8.dp))
Text(
text = item.description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { /* Action */ }) {
Text("ACTION")
}
}
}
}
}
WCAG 2.1 Level AAA:
Minimum: 44×44 pixels
Recommended: 48×48 pixels or larger
Spacing: 8px minimum between targets
// Accessible button component
function AccessibleButton({ children, onPress, variant = 'primary' }) {
return (
<button
className={accessible-button ${variant}}
onClick={onPress}
style={{
minWidth: '48px',
minHeight: '48px',
padding: '12px 24px',
}}
>
{children}
</button>
);
}
Semantic HTML:
function AccessibleMobileNav() {
return (
<nav role="navigation" aria-label="Main navigation">
<ul>
<li>
<a href="/home" aria-current="page">
<Icon name="home" aria-hidden="true" />
<span>Home</span>
</a>
</li>
<li>
<a href="/search">
<Icon name="search" aria-hidden="true" />
<span>Search</span>
</a>
</li>
</ul>
</nav>
);
}
React Native Accessibility:
import { View, Text, TouchableOpacity } from 'react-native';
function AccessibleCard({ title, description, onPress }) {
return (
<TouchableOpacity
accessible={true}
accessibilityLabel={`${title}. ${description}`}
accessibilityRole="button"
accessibilityHint="Double tap to view details"
onPress={onPress}
>
<View>
<Text>{title}</Text>
<Text>{description}</Text>
</View>
</TouchableOpacity>
);
}
WCAG AA Requirements:
Normal text: 4.5:1 contrast ratio
Large text (18pt+): 3:1 contrast ratio
UI components: 3:1 contrast ratio
/* Good contrast examples / .primary-button { background: #0066CC; / Blue / color: #FFFFFF; / White - 6.4:1 ratio */ }
.secondary-button { background: #FFFFFF; /* White / color: #333333; / Dark gray - 12.6:1 ratio */ border: 2px solid #333333; }
/* Bad contrast (avoid) / .bad-button { background: #FFCC00; / Yellow / color: #FFFFFF; / White - 1.4:1 ratio ❌ */ }
/* Visible focus states for keyboard navigation */
button:focus-visible {
outline: 3px solid #007AFF;
outline-offset: 2px;
}
input:focus-visible {
border-color: #007AFF;
box-shadow: 0 0 0 4px rgba(0, 122, 255, 0.2);
}
/* Remove default focus ring, add custom */
*:focus {
outline: none;
}
*:focus-visible {
outline: 3px solid #007AFF;
outline-offset: 2px;
}
Responsive Images:
<!-- Serve different sizes based on screen width -->
<img
src="image-800w.jpg"
srcset="
image-400w.jpg 400w,
image-800w.jpg 800w,
image-1200w.jpg 1200w
"
sizes="
(max-width: 480px) 100vw,
(max-width: 768px) 50vw,
33vw
"
alt="Product image"
loading="lazy"
>
<!-- WebP with fallback -->
<picture>
<source srcset="image.webp" type="image/webp">
<source srcset="image.jpg" type="image/jpeg">
<img src="image.jpg" alt="Fallback image">
</picture>
Lazy Loading:
// React: Intersection Observer for lazy loading
function LazyImage({ src, alt }) {
const [isLoaded, setIsLoaded] = useState(false);
const [isInView, setIsInView] = useState(false);
const imgRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true);
observer.disconnect();
}
},
{ rootMargin: '50px' }
);
if (imgRef.current) {
observer.observe(imgRef.current);
}
return () => observer.disconnect();
}, []);
return (
<div ref={imgRef} className="lazy-image-container">
{!isLoaded && <div className="skeleton-loader" />}
{isInView && (
<img
src={src}
alt={alt}
onLoad={() => setIsLoaded(true)}
style={{ opacity: isLoaded ? 1 : 0 }}
/>
)}
</div>
);
}
Skeleton Screens:
function SkeletonCard() {
return (
<div className="skeleton-card">
<div className="skeleton skeleton-image" />
<div className="skeleton skeleton-title" />
<div className="skeleton skeleton-text" />
<div className="skeleton skeleton-text short" />
</div>
);
}
// CSS
.skeleton {
background: linear-gradient(
90deg,
#f0f0f0 25%,
#e0e0e0 50%,
#f0f0f0 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-image {
height: 200px;
border-radius: 8px 8px 0 0;
}
.skeleton-title {
height: 24px;
margin: 16px;
border-radius: 4px;
}
.skeleton-text {
height: 16px;
margin: 8px 16px;
border-radius: 4px;
}
.skeleton-text.short {
width: 60%;
}
Progressive Web App (PWA):
// service-worker.js
const CACHE_NAME = 'mobile-app-v1';
const urlsToCache = [
'/',
'/styles/main.css',
'/scripts/main.js',
'/images/logo.png',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME)
.then((cache) => cache.addAll(urlsToCache))
);
});
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request)
.then((response) => {
// Return cached version or fetch new
return response || fetch(event.request);
})
);
});
Core Web Vitals for Mobile:
LCP (Largest Contentful Paint): < 2.5s
FID (First Input Delay): < 100ms
CLS (Cumulative Layout Shift): < 0.1
// Measure performance const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.name, entry.startTime); } });
observer.observe({ entryTypes: ['navigation', 'paint', 'largest-contentful-paint'] });
/* iPhone SE (2022) */
@media (min-width: 375px) and (max-width: 667px) {
/* Small phone styles */
}
/* iPhone 12/13/14 Pro */
@media (min-width: 390px) and (max-width: 844px) {
/* Standard phone styles */
}
/* iPhone 14 Pro Max */
@media (min-width: 428px) and (max-width: 926px) {
/* Large phone styles */
}
/* iPad Mini */
@media (min-width: 768px) and (max-width: 1024px) {
/* Tablet styles */
}
/* iPad Pro */
@media (min-width: 1024px) and (max-width: 1366px) {
/* Large tablet styles */
}
/* Portrait mode */
@media (orientation: portrait) {
.container {
flex-direction: column;
}
}
/* Landscape mode */
@media (orientation: landscape) {
.container {
flex-direction: row;
}
.sidebar {
width: 300px;
}
}
/* Prevent layout shift on keyboard open */
@media (max-height: 500px) {
.bottom-nav {
display: none;
}
}
/* Component adapts to container size, not viewport */
.card-container {
container-type: inline-size;
}
@container (min-width: 400px) {
.card {
display: grid;
grid-template-columns: 1fr 2fr;
}
}
@container (min-width: 600px) {
.card {
grid-template-columns: 1fr 1fr;
}
}
function ProductList({ products }) {
return (
<div className="product-list">
{products.map((product) => (
<div key={product.id} className="product-card">
<div className="product-image-container">
<img
src={product.image}
alt={product.name}
loading="lazy"
/>
{product.badge && (
<span className="product-badge">{product.badge}</span>
)}
</div>
<div className="product-info">
<h3 className="product-name">{product.name}</h3>
<p className="product-price">${product.price}</p>
{product.rating && (
<div className="product-rating">
{'★'.repeat(product.rating)}
{'☆'.repeat(5 - product.rating)}
<span className="review-count">
({product.reviewCount})
</span>
</div>
)}
</div>
<button className="add-to-cart-btn">
Add to Cart
</button>
</div>
))}
</div>
);
}
// CSS
.product-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 16px;
}
@media (min-width: 768px) {
.product-list {
grid-template-columns: repeat(3, 1fr);
}
}
@media (min-width: 1024px) {
.product-list {
grid-template-columns: repeat(4, 1fr);
}
}
.product-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.product-image-container {
position: relative;
aspect-ratio: 1;
background: #f5f5f5;
}
.product-image-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.product-badge {
position: absolute;
top: 8px;
right: 8px;
background: #FF3B30;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.product-info {
padding: 12px;
}
.product-name {
font-size: 14px;
font-weight: 600;
margin: 0 0 4px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.product-price {
font-size: 16px;
font-weight: 700;
color: #007AFF;
margin: 0 0 8px 0;
}
.product-rating {
font-size: 14px;
color: #FFB800;
}
.review-count {
color: #666;
font-size: 12px;
margin-left: 4px;
}
.add-to-cart-btn {
width: 100%;
height: 44px;
background: #007AFF;
color: white;
border: none;
font-size: 14px;
font-weight: 600;
-webkit-tap-highlight-color: transparent;
}
.add-to-cart-btn:active {
background: #0051D5;
}
function InfiniteFeed() {
const [posts, setPosts] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef(null);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
const newPosts = await fetchPosts(page);
if (newPosts.length === 0) {
setHasMore(false);
} else {
setPosts(prev => [...prev, ...newPosts]);
setPage(prev => prev + 1);
}
setLoading(false);
};
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 0.5 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [loading, hasMore]);
return (
<div className="feed">
{posts.map(post => (
<FeedCard key={post.id} post={post} />
))}
{loading && <LoadingSpinner />}
<div ref={observerTarget} style={{ height: '20px' }} />
{!hasMore && (
<div className="feed-end">No more posts</div>
)}
</div>
);
}
function MobileSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isFocused, setIsFocused] = useState(false);
const [recentSearches, setRecentSearches] = useState([]);
const handleSearch = async (value) => {
setQuery(value);
if (value.length >= 2) {
const searchResults = await fetchSearchResults(value);
setResults(searchResults);
} else {
setResults([]);
}
};
const handleSubmit = (searchQuery) => {
// Save to recent searches
const updated = [searchQuery, ...recentSearches.slice(0, 4)];
setRecentSearches(updated);
localStorage.setItem('recentSearches', JSON.stringify(updated));
// Navigate to results
window.location.href = `/search?q=${encodeURIComponent(searchQuery)}`;
};
return (
<div className="mobile-search">
<div className="search-bar">
<input
type="search"
inputMode="search"
placeholder="Search products..."
value={query}
onChange={(e) => handleSearch(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setTimeout(() => setIsFocused(false), 200)}
/>
{query && (
<button
className="clear-button"
onClick={() => {
setQuery('');
setResults([]);
}}
>
✕
</button>
)}
</div>
{isFocused && (
<div className="search-dropdown">
{query.length === 0 && recentSearches.length > 0 && (
<div className="recent-searches">
<h4>Recent Searches</h4>
{recentSearches.map((search, index) => (
<button
key={index}
className="search-suggestion"
onClick={() => handleSubmit(search)}
>
<span className="icon">🕐</span>
{search}
</button>
))}
</div>
)}
{results.length > 0 && (
<div className="search-results">
{results.map((result) => (
<button
key={result.id}
className="search-result-item"
onClick={() => handleSubmit(result.name)}
>
<img src={result.thumbnail} alt="" />
<div>
<div className="result-name">{result.name}</div>
<div className="result-category">{result.category}</div>
</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
function FilterDrawer({ isOpen, onClose, onApply }) {
const [filters, setFilters] = useState({
priceRange: [0, 1000],
category: [],
rating: 0,
inStock: false,
});
return (
<>
{isOpen && (
<div className="filter-drawer-overlay" onClick={onClose} />
)}
<div className={`filter-drawer ${isOpen ? 'open' : ''}`}>
<div className="filter-header">
<h2>Filters</h2>
<button onClick={onClose}>✕</button>
</div>
<div className="filter-content">
<div className="filter-section">
<h3>Price Range</h3>
<input
type="range"
min="0"
max="1000"
value={filters.priceRange[1]}
onChange={(e) => setFilters({
...filters,
priceRange: [0, parseInt(e.target.value)]
})}
/>
<div className="price-display">
${filters.priceRange[0]} - ${filters.priceRange[1]}
</div>
</div>
<div className="filter-section">
<h3>Category</h3>
{['Electronics', 'Clothing', 'Books', 'Home'].map(cat => (
<label key={cat} className="checkbox-label">
<input
type="checkbox"
checked={filters.category.includes(cat)}
onChange={(e) => {
if (e.target.checked) {
setFilters({
...filters,
category: [...filters.category, cat]
});
} else {
setFilters({
...filters,
category: filters.category.filter(c => c !== cat)
});
}
}}
/>
{cat}
</label>
))}
</div>
<div className="filter-section">
<label className="checkbox-label">
<input
type="checkbox"
checked={filters.inStock}
onChange={(e) => setFilters({
...filters,
inStock: e.target.checked
})}
/>
In Stock Only
</label>
</div>
</div>
<div className="filter-actions">
<button
className="clear-button"
onClick={() => setFilters({
priceRange: [0, 1000],
category: [],
rating: 0,
inStock: false,
})}
>
Clear All
</button>
<button
className="apply-button"
onClick={() => {
onApply(filters);
onClose();
}}
>
Apply Filters
</button>
</div>
</div>
</>
);
}
// CSS
.filter-drawer {
position: fixed;
right: -100%;
top: 0;
bottom: 0;
width: 85%;
max-width: 400px;
background: white;
z-index: 1001;
transition: right 0.3s;
display: flex;
flex-direction: column;
}
.filter-drawer.open {
right: 0;
}
.filter-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #E5E5EA;
}
.filter-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.filter-section {
margin-bottom: 24px;
}
.filter-section h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
padding: 12px 0;
font-size: 15px;
}
.checkbox-label input {
margin-right: 12px;
width: 20px;
height: 20px;
}
.filter-actions {
display: flex;
gap: 12px;
padding: 16px;
border-top: 1px solid #E5E5EA;
}
.clear-button,
.apply-button {
flex: 1;
height: 48px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
}
.clear-button {
background: white;
border: 2px solid #007AFF;
color: #007AFF;
}
.apply-button {
background: #007AFF;
border: none;
color: white;
}
function MobilePaymentForm() {
const [cardNumber, setCardNumber] = useState('');
const [expiry, setExpiry] = useState('');
const [cvv, setCvv] = useState('');
const formatCardNumber = (value) => {
return value
.replace(/\s/g, '')
.match(/.{1,4}/g)
?.join(' ') || '';
};
const formatExpiry = (value) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length >= 2) {
return `${cleaned.slice(0, 2)}/${cleaned.slice(2, 4)}`;
}
return cleaned;
};
return (
<form className="payment-form">
<div className="form-group">
<label>Card Number</label>
<input
type="text"
inputMode="numeric"
maxLength="19"
placeholder="1234 5678 9012 3456"
value={formatCardNumber(cardNumber)}
onChange={(e) => setCardNumber(e.target.value.replace(/\s/g, ''))}
/>
</div>
<div className="form-row">
<div className="form-group">
<label>Expiry</label>
<input
type="text"
inputMode="numeric"
maxLength="5"
placeholder="MM/YY"
value={expiry}
onChange={(e) => setExpiry(formatExpiry(e.target.value))}
/>
</div>
<div className="form-group">
<label>CVV</label>
<input
type="text"
inputMode="numeric"
maxLength="4"
placeholder="123"
value={cvv}
onChange={(e) => setCvv(e.target.value.replace(/\D/g, ''))}
/>
</div>
</div>
<button type="submit" className="pay-button">
Pay $99.99
</button>
</form>
);
}
For brevity, here are summaries of 14 more essential mobile design patterns:
6. Sticky Header with Scroll Progress
7. Image Gallery with Pinch Zoom
8. Mobile-Optimized Data Table
9. Bottom Sheet Menu
10. Mobile Calendar Picker
11. Floating Action Button (FAB) with Speed Dial
12. Pull to Refresh
13. Swipeable Tabs
14. Mobile Video Player
15. Mobile Toast Notifications
16. Collapsible Accordion
17. Mobile Stepper Form
18. Voice Input Interface
19. Onboarding Carousel
20. Mobile Share Sheet
Mobile design requires deep understanding of touch interactions, platform conventions, and performance optimization. By following mobile-first principles, respecting thumb zones, and implementing platform-appropriate patterns, you create experiences that feel natural and performant on mobile devices.
Remember: mobile users are often on-the-go, have limited attention, and expect instant responsiveness. Prioritize speed, clarity, and ease of use above all else.
Weekly Installs
74
Repository
GitHub Stars
44
First Seen
Jan 22, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykWarn
Installed on
gemini-cli61
opencode61
codex59
github-copilot56
cursor54
amp50
前端打磨(Polish)终极指南:提升产品细节与用户体验的系统化检查清单
54,900 周安装
); }