---
name: mobile-components
description: Mobile-first UI components including bottom navigation, bottom sheets, pull-to-refresh, and swipe actions. Touch-optimized with proper gesture handling.
license: MIT
compatibility: TypeScript/JavaScript, React
metadata:
category: frontend
time: 3h
source: drift-masterguide
---
# Mobile Components
Touch-optimized UI components for mobile-first experiences.
## 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
## 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.
## Implementation
### TypeScript/React
```typescript
// Bottom Navigation
interface NavItem {
href: string;
label: string;
icon: string;
}
function MobileNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
return (
{items.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
{item.icon}
{item.label}
);
})}
);
}
// 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 (
<>
{title && (
{title}
✕
)}
{children}
>
);
}
// Pull to Refresh
function PullToRefresh({
onRefresh,
children
}: {
onRefresh: () => Promise;
children: ReactNode;
}) {
const [isPulling, setIsPulling] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const startY = useRef(0);
const containerRef = useRef(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 (
{isRefreshing ? (
) : (
↓
)}
{children}
);
}
// 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 (
{leftAction && (
{leftAction}
)}
{rightAction && (
{rightAction}
)}
{children}
);
}
```
## Usage Examples
```typescript
export default function DashboardPage() {
const [sheetOpen, setSheetOpen] = useState(false);
const handleRefresh = async () => {
await fetchData();
};
return (
{items.map((item) => (
deleteItem(item.id)}
rightAction={🗑️ }
>
setSheetOpen(true)}>
{item.title}
))}
setSheetOpen(false)} title="Details">
Sheet content here
);
}
```
## 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
## 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)