git:20260717.94f726c to git:20260817.5b93a21

15 added, 2 removed. Audit A to A.

---
name: design-mobile-first
- description: Designs mobile-first responsive interfaces with touch optimization — breakpoint strategy, touch targets, safe areas, and gesture handling, enhanced progressively for larger screens. Use when the user mentions "mobile", "responsive", "touch", "PWA", "small screen", "tablet", "swipe", or "gesture", or when a UI looks desktop-only on a phone viewport.
+ description: >
+ Design mobile-first UIs: touch targets, safe areas, gestures, then enhance up.
+ Use when building for small screens, touch, swipe, PWA, or tablet. Linearized
+ desktop / responsive audit → audit-responsive.
license: MIT
---
# Mobile-First Design Skill
- Build interfaces that work beautifully on mobile devices first, then enhance for larger screens.
+ Build interfaces that work on mobile first, then enhance for larger screens.
+ **Not this skill:** a desktop that is a linearized phone (no max-width, stacked
+ at 1440, stretched buttons). That is `audit-responsive`.
+
## CRITICAL: Check Existing First
**Before ANY mobile optimization, verify:**
1. **Check existing breakpoints:**
```bash
cat tailwind.config.* | grep -A10 "screens\|breakpoints"
rg "sm:|md:|lg:|xl:" --type tsx | head -20
```
2. **Check for existing mobile patterns:**
```bash
rg "useMediaQuery|useBreakpoint|isMobile" --type ts --type tsx
ls -la src/hooks/use*Mobile* src/hooks/use*Responsive* 2>/dev/null
```
3. **Check viewport meta:**
```bash
rg "viewport" src/app/layout.tsx index.html
```
**Why:** Maintain consistent responsive patterns across the app.
## Mobile-First Principles
### 1. Design for Mobile First
```css
/* Base styles = mobile */
.card {
padding: 1rem;
font-size: 0.875rem;
}
/* Then enhance for larger screens */
@media (min-width: 640px) {
.card {
padding: 1.5rem;
font-size: 1rem;
}
}
/* Tailwind design-mobile-first */
<div className="p-4 sm:p-6 md:p-8">
<div className="text-sm sm:text-base lg:text-lg">
<div className="grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
```
### 2. Touch Targets (44px minimum)
```tsx
// Touch-friendly button
<button className="min-h-[44px] min-w-[44px] px-4 py-3">
Tap me
</button>
// Touch-friendly list items
<li className="py-3 px-4 min-h-[44px] active:bg-muted">
List item
</li>
// Icon buttons with proper touch area
<button
className="p-3 -m-3" // Negative margin extends touch area
aria-label="Menu"
>
<MenuIcon className="h-5 w-5" />
</button>
```
### 3. Thumb-Friendly Zones
```tsx
// Bottom navigation for primary actions
<nav className="fixed bottom-0 left-0 right-0 border-t bg-background safe-area-pb">
<div className="flex justify-around py-2">
<NavItem icon={HomeIcon} label="Home" />
<NavItem icon={SearchIcon} label="Search" />
<NavItem icon={PlusIcon} label="Create" />
<NavItem icon={BellIcon} label="Alerts" />
<NavItem icon={UserIcon} label="Profile" />
</div>
</nav>
// Safe area padding for notched devices
.safe-area-pb {
padding-bottom: env(safe-area-inset-bottom);
}
```
## Responsive Patterns
### Mobile Navigation
```tsx
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
export function MobileNav() {
const [isOpen, setIsOpen] = useState(false)
return (
<>
<button
className="lg:hidden p-2"
onClick={() => setIsOpen(true)}
aria-label="Open menu"
>
<MenuIcon className="h-6 w-6" />
</button>
<AnimatePresence>
{isOpen && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
onClick={() => setIsOpen(false)}
/>
{/* Drawer */}
<motion.div
initial={{ x: '-100%' }}
animate={{ x: 0 }}
exit={{ x: '-100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed inset-y-0 left-0 w-[280px] bg-background z-50 lg:hidden"
>
<div className="p-4">
<button
className="absolute top-4 right-4 p-2"
onClick={() => setIsOpen(false)}
>
<XIcon className="h-6 w-6" />
</button>
<nav className="mt-8 space-y-2">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
className="block py-3 px-4 rounded-lg hover:bg-muted"
>
{item.label}
</a>
))}
</nav>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</>
)
}
```
### Responsive Tables
```tsx
// Card-based layout on mobile, table on desktop
export function ResponsiveTable({ data }: { data: Item[] }) {
return (
<>
{/* Mobile: Card layout */}
<div className="space-y-4 md:hidden">
{data.map((item) => (
<div key={item.id} className="rounded-lg border p-4">
<div className="flex justify-between">
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground">{item.date}</span>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{item.description}
</div>
<div className="mt-3 flex justify-between items-center">
<span className="font-semibold">{item.amount}</span>
<Badge>{item.status}</Badge>
</div>
</div>
))}
</div>
{/* Desktop: Traditional table */}
<table className="hidden md:table w-full">
<thead>
<tr className="border-b">
<th className="text-left py-3">Name</th>
<th className="text-left py-3">Date</th>
<th className="text-left py-3">Amount</th>
<th className="text-left py-3">Status</th>
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr key={item.id} className="border-b">
<td className="py-3">{item.name}</td>
<td className="py-3">{item.date}</td>
<td className="py-3">{item.amount}</td>
<td className="py-3"><Badge>{item.status}</Badge></td>
</tr>
))}
</tbody>
</table>
</>
)
}
```
### Responsive Grid
```tsx
// Fluid grid that adapts to screen size
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{items.map((item) => (
<Card key={item.id}>{item.content}</Card>
))}
</div>
// Auto-fit grid (items wrap naturally)
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
{items.map((item) => (
<Card key={item.id}>{item.content}</Card>
))}
</div>
```
## Touch Gestures
### Swipe to Delete
```tsx
'use client'
import { motion, useMotionValue, useTransform, PanInfo } from 'framer-motion'
export function SwipeableItem({ onDelete, children }: SwipeableItemProps) {
const x = useMotionValue(0)
const background = useTransform(
x,
[-100, 0],
['rgb(239 68 68)', 'rgb(255 255 255)']
)
const handleDragEnd = (_: any, info: PanInfo) => {
if (info.offset.x < -100) {
onDelete()
}
}
return (
<motion.div style={{ background }} className="relative overflow-hidden rounded-lg">
<motion.div
drag="x"
dragConstraints={{ left: -100, right: 0 }}
onDragEnd={handleDragEnd}
style={{ x }}
className="bg-background p-4"
>
{children}
</motion.div>
<div className="absolute right-4 top-1/2 -translate-y-1/2 text-white">
<TrashIcon className="h-5 w-5" />
</div>
</motion.div>
)
}
```
### Pull to Refresh
```tsx
'use client'
import { useState, useRef } from 'react'
import { motion, useMotionValue, useTransform } from 'framer-motion'
export function PullToRefresh({ onRefresh, children }: PullToRefreshProps) {
const [isRefreshing, setIsRefreshing] = useState(false)
const y = useMotionValue(0)
const opacity = useTransform(y, [0, 60], [0, 1])
const scale = useTransform(y, [0, 60], [0.5, 1])
const handleDragEnd = async () => {
if (y.get() > 60) {
setIsRefreshing(true)
await onRefresh()
setIsRefreshing(false)
}
}
return (
<div className="overflow-hidden">
<motion.div
style={{ opacity, scale }}
className="flex justify-center py-4"
>
<RefreshIcon className={cn('h-6 w-6', isRefreshing && 'animate-spin')} />
</motion.div>
<motion.div
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={0.5}
onDragEnd={handleDragEnd}
style={{ y }}
>
{children}
</motion.div>
</div>
)
}
```
## Mobile-Specific Components
### Bottom Sheet
```tsx
'use client'
import { motion, useDragControls, PanInfo } from 'framer-motion'
export function BottomSheet({ isOpen, onClose, children }: BottomSheetProps) {
const controls = useDragControls()
const handleDragEnd = (_: any, info: PanInfo) => {
if (info.velocity.y > 500 || info.offset.y > 200) {
onClose()
}
}
if (!isOpen) return null
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-40"
onClick={onClose}
/>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
drag="y"
dragControls={controls}
dragConstraints={{ top: 0 }}
dragElastic={0.2}
onDragEnd={handleDragEnd}
className="fixed bottom-0 left-0 right-0 bg-background rounded-t-xl z-50 max-h-[90vh] overflow-hidden"
>
{/* Drag handle */}
<div
className="flex justify-center py-3 cursor-grab active:cursor-grabbing"
onPointerDown={(e) => controls.start(e)}
>
<div className="w-12 h-1.5 bg-muted-foreground/30 rounded-full" />
</div>
<div className="px-4 pb-safe overflow-y-auto max-h-[calc(90vh-40px)]">
{children}
</div>
</motion.div>
</>
)
}
```
### Mobile-Optimized Forms
```tsx
// Stack form fields vertically on mobile
<form className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
// Larger touch targets
className="h-12 text-base"
// Prevent iOS zoom on focus
style={{ fontSize: '16px' }}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
className="h-12 text-base"
style={{ fontSize: '16px' }}
/>
</div>
</div>
{/* Full-width button on mobile */}
<Button className="w-full sm:w-auto h-12">
Submit
</Button>
</form>
```
## PWA Features
### Viewport & Meta Tags
```tsx
// app/layout.tsx
export const metadata = {
viewport: {
width: 'device-width',
initialScale: 1,
maximumScale: 1, // Prevents zoom on input focus
userScalable: false,
viewportFit: 'cover', // For notched devices
},
appleWebApp: {
capable: true,
statusBarStyle: 'default',
title: 'My App',
},
}
```
### Install Prompt
```tsx
'use client'
import { useEffect, useState } from 'react'
export function InstallPrompt() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e)
}
window.addEventListener('beforeinstallprompt', handler)
return () => window.removeEventListener('beforeinstallprompt', handler)
}, [])
if (!deferredPrompt) return null
const handleInstall = async () => {
deferredPrompt.prompt()
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
setDeferredPrompt(null)
}
}
return (
<div className="fixed bottom-20 left-4 right-4 p-4 bg-card border rounded-lg shadow-lg">
<p className="font-medium">Install our app</p>
<p className="text-sm text-muted-foreground">Get quick access from your home screen</p>
<div className="mt-3 flex gap-2">
<Button onClick={handleInstall}>Install</Button>
<Button variant="ghost" onClick={() => setDeferredPrompt(null)}>
Not now
</Button>
</div>
</div>
)
}
```
## Validation
After implementing mobile features:
1. **Touch targets** → All interactive elements ≥44px
2. **Thumb zones** → Primary actions reachable with thumb
3. **Gestures** → Natural, discoverable, with visual feedback
4. **Performance** → 60fps scrolling, no jank
5. **Forms** → No zoom on focus, appropriate keyboards
6. **Safe areas** → Content respects notches and home indicators
7. **Offline** → Graceful handling when offline
8. **Real device test** → Test on actual phones, not just emulators
+
+ ## Related
+
+ - `audit-responsive` — unstack desktop; layout/IA at 375 / 768 / 1440
+ - `enhance-capacitor-ui` — hybrid web + native form-factor axes
+ - `design-frontend` — new visual surfaces
+ - `audit-performance` — mobile perf budgets