mobile-components

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Mobile Components

移动端组件

Touch-optimized UI components for mobile-first experiences.
为移动端优先体验打造的触摸优化UI组件。

When to Use This Skill

何时使用该组件

  • Building mobile-first or responsive web applications
  • Need native-feeling mobile interactions
  • Implementing bottom sheets, pull-to-refresh, or swipe actions
  • Desktop components feel awkward on mobile
  • 构建移动端优先或响应式Web应用
  • 需要具备原生移动端交互体验
  • 实现底部弹窗、下拉刷新或滑动操作
  • 桌面端组件在移动端使用体验不佳

Core Concepts

核心概念

Mobile UX differs from desktop: bottom navigation is reachable, sheets slide up from bottom, touch targets need 44px minimum, and gestures replace clicks.
移动端用户体验与桌面端不同:底部导航更易触达,弹窗从底部滑入,触摸目标最小需44px,手势操作替代点击。

Implementation

实现

TypeScript/React

TypeScript/React

typescript
// Bottom Navigation
interface NavItem {
  href: string;
  label: string;
  icon: string;
}

function MobileNav({ items }: { items: NavItem[] }) {
  const pathname = usePathname();

  return (
    <nav className="fixed bottom-0 left-0 right-0 bg-neutral-800 border-t border-neutral-700 z-30 md:hidden safe-area-bottom">
      <div className="flex items-center justify-around py-2">
        {items.map((item) => {
          const isActive = pathname.startsWith(item.href);
          return (
            <Link
              key={item.href}
              href={item.href}
              className={`flex flex-col items-center gap-1 px-4 py-2 min-w-[64px] ${
                isActive ? 'text-primary-400' : 'text-neutral-500'
              }`}
            >
              <span className="text-xl">{item.icon}</span>
              <span className="text-xs">{item.label}</span>
            </Link>
          );
        })}
      </div>
    </nav>
  );
}

// Bottom Sheet
interface BottomSheetProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  title?: string;
}

function BottomSheet({ isOpen, onClose, children, title }: BottomSheetProps) {
  const [dragY, setDragY] = useState(0);
  const [isDragging, setIsDragging] = useState(false);
  const startY = useRef(0);

  useEffect(() => {
    document.body.style.overflow = isOpen ? 'hidden' : '';
    return () => { document.body.style.overflow = ''; };
  }, [isOpen]);

  const handleTouchStart = (e: React.TouchEvent) => {
    startY.current = e.touches[0].clientY;
    setIsDragging(true);
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!isDragging) return;
    const diff = e.touches[0].clientY - startY.current;
    if (diff > 0) setDragY(diff);
  };

  const handleTouchEnd = () => {
    setIsDragging(false);
    if (dragY > 100) onClose();
    setDragY(0);
  };

  if (!isOpen) return null;

  return (
    <>
      <div className="fixed inset-0 bg-black/60 z-40" onClick={onClose} />
      <div
        className="fixed bottom-0 left-0 right-0 bg-neutral-800 rounded-t-2xl z-50 max-h-[90vh]"
        style={{
          transform: `translateY(${dragY}px)`,
          transition: isDragging ? 'none' : 'transform 0.3s ease-out',
        }}
      >
        <div
          className="flex justify-center py-3 cursor-grab touch-none"
          onTouchStart={handleTouchStart}
          onTouchMove={handleTouchMove}
          onTouchEnd={handleTouchEnd}
        >
          <div className="w-10 h-1 bg-neutral-600 rounded-full" />
        </div>
        {title && (
          <div className="px-4 pb-3 border-b border-neutral-700 flex justify-between">
            <h2 className="text-lg font-semibold">{title}</h2>
            <button onClick={onClose} aria-label="Close"></button>
          </div>
        )}
        <div className="overflow-y-auto p-4">{children}</div>
      </div>
    </>
  );
}

// Pull to Refresh
function PullToRefresh({ 
  onRefresh, 
  children 
}: { 
  onRefresh: () => Promise<void>; 
  children: ReactNode;
}) {
  const [isPulling, setIsPulling] = useState(false);
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [pullDistance, setPullDistance] = useState(0);
  const startY = useRef(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const THRESHOLD = 80;

  const handleTouchStart = (e: React.TouchEvent) => {
    if (containerRef.current?.scrollTop === 0) {
      startY.current = e.touches[0].clientY;
      setIsPulling(true);
    }
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!isPulling || isRefreshing) return;
    const diff = e.touches[0].clientY - startY.current;
    if (diff > 0) {
      setPullDistance(Math.min(diff * 0.5, THRESHOLD * 1.5));
    }
  };

  const handleTouchEnd = async () => {
    if (!isPulling) return;
    if (pullDistance >= THRESHOLD && !isRefreshing) {
      setIsRefreshing(true);
      setPullDistance(THRESHOLD);
      try { await onRefresh(); } 
      finally { setIsRefreshing(false); }
    }
    setIsPulling(false);
    setPullDistance(0);
  };

  return (
    <div
      ref={containerRef}
      className="h-full overflow-y-auto"
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
    >
      <div
        className="flex items-center justify-center overflow-hidden"
        style={{ height: pullDistance }}
      >
        {isRefreshing ? (
          <div className="animate-spin w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full" />
        ) : (
          <div style={{ transform: `rotate(${(pullDistance / THRESHOLD) * 180}deg)` }}></div>
        )}
      </div>
      {children}
    </div>
  );
}

// Swipeable Row
function SwipeableRow({
  children,
  onSwipeLeft,
  onSwipeRight,
  leftAction,
  rightAction,
}: {
  children: ReactNode;
  onSwipeLeft?: () => void;
  onSwipeRight?: () => void;
  leftAction?: ReactNode;
  rightAction?: ReactNode;
}) {
  const [translateX, setTranslateX] = useState(0);
  const startX = useRef(0);
  const isDragging = useRef(false);
  const THRESHOLD = 80;

  const handleTouchStart = (e: React.TouchEvent) => {
    startX.current = e.touches[0].clientX;
    isDragging.current = true;
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!isDragging.current) return;
    const diff = e.touches[0].clientX - startX.current;
    setTranslateX(Math.max(-100, Math.min(100, diff)));
  };

  const handleTouchEnd = () => {
    isDragging.current = false;
    if (translateX > THRESHOLD) onSwipeRight?.();
    else if (translateX < -THRESHOLD) onSwipeLeft?.();
    setTranslateX(0);
  };

  return (
    <div className="relative overflow-hidden">
      {leftAction && (
        <div className="absolute left-0 top-0 bottom-0 flex items-center px-4 bg-green-600">
          {leftAction}
        </div>
      )}
      {rightAction && (
        <div className="absolute right-0 top-0 bottom-0 flex items-center px-4 bg-red-600">
          {rightAction}
        </div>
      )}
      <div
        className="relative bg-neutral-800"
        style={{
          transform: `translateX(${translateX}px)`,
          transition: isDragging.current ? 'none' : 'transform 0.2s ease-out',
        }}
        onTouchStart={handleTouchStart}
        onTouchMove={handleTouchMove}
        onTouchEnd={handleTouchEnd}
      >
        {children}
      </div>
    </div>
  );
}
typescript
// Bottom Navigation
interface NavItem {
  href: string;
  label: string;
  icon: string;
}

function MobileNav({ items }: { items: NavItem[] }) {
  const pathname = usePathname();

  return (
    <nav className="fixed bottom-0 left-0 right-0 bg-neutral-800 border-t border-neutral-700 z-30 md:hidden safe-area-bottom">
      <div className="flex items-center justify-around py-2">
        {items.map((item) => {
          const isActive = pathname.startsWith(item.href);
          return (
            <Link
              key={item.href}
              href={item.href}
              className={`flex flex-col items-center gap-1 px-4 py-2 min-w-[64px] ${
                isActive ? 'text-primary-400' : 'text-neutral-500'
              }`}
            >
              <span className="text-xl">{item.icon}</span>
              <span className="text-xs">{item.label}</span>
            </Link>
          );
        })}
      </div>
    </nav>
  );
}

// Bottom Sheet
interface BottomSheetProps {
  isOpen: boolean;
  onClose: () => void;
  children: ReactNode;
  title?: string;
}

function BottomSheet({ isOpen, onClose, children, title }: BottomSheetProps) {
  const [dragY, setDragY] = useState(0);
  const [isDragging, setIsDragging] = useState(false);
  const startY = useRef(0);

  useEffect(() => {
    document.body.style.overflow = isOpen ? 'hidden' : '';
    return () => { document.body.style.overflow = ''; };
  }, [isOpen]);

  const handleTouchStart = (e: React.TouchEvent) => {
    startY.current = e.touches[0].clientY;
    setIsDragging(true);
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!isDragging) return;
    const diff = e.touches[0].clientY - startY.current;
    if (diff > 0) setDragY(diff);
  };

  const handleTouchEnd = () => {
    setIsDragging(false);
    if (dragY > 100) onClose();
    setDragY(0);
  };

  if (!isOpen) return null;

  return (
    <>
      <div className="fixed inset-0 bg-black/60 z-40" onClick={onClose} />
      <div
        className="fixed bottom-0 left-0 right-0 bg-neutral-800 rounded-t-2xl z-50 max-h-[90vh]"
        style={{
          transform: `translateY(${dragY}px)`,
          transition: isDragging ? 'none' : 'transform 0.3s ease-out',
        }}
      >
        <div
          className="flex justify-center py-3 cursor-grab touch-none"
          onTouchStart={handleTouchStart}
          onTouchMove={handleTouchMove}
          onTouchEnd={handleTouchEnd}
        >
          <div className="w-10 h-1 bg-neutral-600 rounded-full" />
        </div>
        {title && (
          <div className="px-4 pb-3 border-b border-neutral-700 flex justify-between">
            <h2 className="text-lg font-semibold">{title}</h2>
            <button onClick={onClose} aria-label="Close"></button>
          </div>
        )}
        <div className="overflow-y-auto p-4">{children}</div>
      </div>
    </>
  );
}

// Pull to Refresh
function PullToRefresh({ 
  onRefresh, 
  children 
}: { 
  onRefresh: () => Promise<void>; 
  children: ReactNode;
}) {
  const [isPulling, setIsPulling] = useState(false);
  const [isRefreshing, setIsRefreshing] = useState(false);
  const [pullDistance, setPullDistance] = useState(0);
  const startY = useRef(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const THRESHOLD = 80;

  const handleTouchStart = (e: React.TouchEvent) => {
    if (containerRef.current?.scrollTop === 0) {
      startY.current = e.touches[0].clientY;
      setIsPulling(true);
    }
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!isPulling || isRefreshing) return;
    const diff = e.touches[0].clientY - startY.current;
    if (diff > 0) {
      setPullDistance(Math.min(diff * 0.5, THRESHOLD * 1.5));
    }
  };

  const handleTouchEnd = async () => {
    if (!isPulling) return;
    if (pullDistance >= THRESHOLD && !isRefreshing) {
      setIsRefreshing(true);
      setPullDistance(THRESHOLD);
      try { await onRefresh(); } 
      finally { setIsRefreshing(false); }
    }
    setIsPulling(false);
    setPullDistance(0);
  };

  return (
    <div
      ref={containerRef}
      className="h-full overflow-y-auto"
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
    >
      <div
        className="flex items-center justify-center overflow-hidden"
        style={{ height: pullDistance }}
      >
        {isRefreshing ? (
          <div className="animate-spin w-6 h-6 border-2 border-primary-500 border-t-transparent rounded-full" />
        ) : (
          <div style={{ transform: `rotate(${(pullDistance / THRESHOLD) * 180}deg)` }}></div>
        )}
      </div>
      {children}
    </div>
  );
}

// Swipeable Row
function SwipeableRow({
  children,
  onSwipeLeft,
  onSwipeRight,
  leftAction,
  rightAction,
}: {
  children: ReactNode;
  onSwipeLeft?: () => void;
  onSwipeRight?: () => void;
  leftAction?: ReactNode;
  rightAction?: ReactNode;
}) {
  const [translateX, setTranslateX] = useState(0);
  const startX = useRef(0);
  const isDragging = useRef(false);
  const THRESHOLD = 80;

  const handleTouchStart = (e: React.TouchEvent) => {
    startX.current = e.touches[0].clientX;
    isDragging.current = true;
  };

  const handleTouchMove = (e: React.TouchEvent) => {
    if (!isDragging.current) return;
    const diff = e.touches[0].clientX - startX.current;
    setTranslateX(Math.max(-100, Math.min(100, diff)));
  };

  const handleTouchEnd = () => {
    isDragging.current = false;
    if (translateX > THRESHOLD) onSwipeRight?.();
    else if (translateX < -THRESHOLD) onSwipeLeft?.();
    setTranslateX(0);
  };

  return (
    <div className="relative overflow-hidden">
      {leftAction && (
        <div className="absolute left-0 top-0 bottom-0 flex items-center px-4 bg-green-600">
          {leftAction}
        </div>
      )}
      {rightAction && (
        <div className="absolute right-0 top-0 bottom-0 flex items-center px-4 bg-red-600">
          {rightAction}
        </div>
      )}
      <div
        className="relative bg-neutral-800"
        style={{
          transform: `translateX(${translateX}px)`,
          transition: isDragging.current ? 'none' : 'transform 0.2s ease-out',
        }}
        onTouchStart={handleTouchStart}
        onTouchMove={handleTouchMove}
        onTouchEnd={handleTouchEnd}
      >
        {children}
      </div>
    </div>
  );
}

Usage Examples

使用示例

typescript
export default function DashboardPage() {
  const [sheetOpen, setSheetOpen] = useState(false);

  const handleRefresh = async () => {
    await fetchData();
  };

  return (
    <div className="min-h-screen pb-20 md:pb-0">
      <PullToRefresh onRefresh={handleRefresh}>
        <div className="p-4">
          {items.map((item) => (
            <SwipeableRow
              key={item.id}
              onSwipeLeft={() => deleteItem(item.id)}
              rightAction={<span>🗑️</span>}
            >
              <ListItem onClick={() => setSheetOpen(true)}>
                {item.title}
              </ListItem>
            </SwipeableRow>
          ))}
        </div>
      </PullToRefresh>

      <BottomSheet isOpen={sheetOpen} onClose={() => setSheetOpen(false)} title="Details">
        <p>Sheet content here</p>
      </BottomSheet>

      <MobileNav items={[
        { href: '/dashboard', label: 'Home', icon: '🏠' },
        { href: '/settings', label: 'Settings', icon: '⚙️' },
      ]} />
    </div>
  );
}
typescript
export default function DashboardPage() {
  const [sheetOpen, setSheetOpen] = useState(false);

  const handleRefresh = async () => {
    await fetchData();
  };

  return (
    <div className="min-h-screen pb-20 md:pb-0">
      <PullToRefresh onRefresh={handleRefresh}>
        <div className="p-4">
          {items.map((item) => (
            <SwipeableRow
              key={item.id}
              onSwipeLeft={() => deleteItem(item.id)}
              rightAction={<span>🗑️</span>}
            >
              <ListItem onClick={() => setSheetOpen(true)}>
                {item.title}
              </ListItem>
            </SwipeableRow>
          ))}
        </div>
      </PullToRefresh>

      <BottomSheet isOpen={sheetOpen} onClose={() => setSheetOpen(false)} title="Details">
        <p>Sheet content here</p>
      </BottomSheet>

      <MobileNav items={[
        { href: '/dashboard', label: 'Home', icon: '🏠' },
        { href: '/settings', label: 'Settings', icon: '⚙️' },
      ]} />
    </div>
  );
}

Best Practices

最佳实践

  1. Minimum touch target: 44x44px (Apple) / 48x48dp (Google)
  2. Use
    safe-area-bottom
    for bottom navigation on notched devices
  3. Always provide visual feedback on touch (active states)
  4. Lock body scroll when sheets are open
  5. Use
    touch-none
    on drag handles to prevent scroll interference
  1. 最小触摸目标:44x44px(苹果标准)/ 48x48dp(谷歌标准)
  2. 在带刘海的设备上,为底部导航使用
    safe-area-bottom
  3. 触摸操作时始终提供视觉反馈(激活状态)
  4. 弹窗打开时锁定页面滚动
  5. 在拖拽手柄上使用
    touch-none
    以避免滚动干扰

Common Mistakes

常见错误

  • Touch targets too small (causes mis-taps)
  • Not handling safe areas (content hidden behind notch)
  • Missing active states (no touch feedback)
  • Forgetting to unlock body scroll on unmount
  • Not debouncing pull-to-refresh
  • 触摸目标过小(导致误触)
  • 未处理安全区域(内容被刘海遮挡)
  • 缺少激活状态(无触摸反馈)
  • 忘记在卸载时解锁页面滚动
  • 未对下拉刷新做防抖处理

Related Patterns

相关模式

  • design-tokens (consistent spacing/sizing)
  • pwa-setup (full mobile experience)
  • design-tokens(统一间距/尺寸)
  • pwa-setup(完整移动端体验)