
Mobile Ux Optimizer
- 349 installs
- 178 repo stars
- Updated July 14, 2026
- erichowens/some_claude_skills
mobile-ux-optimizer is a Claude Code skill that applies mobile-first UX patterns—44px touch targets, 100dvh viewports, safe-area insets, and swipe navigation—for developers shipping responsive web and PWA interfaces.
About
mobile-ux-optimizer is a Claude Code skill from erichowens/some_claude_skills for touch-optimized responsive web UX, not native iOS or Android apps. It encodes Apple’s 44×44pt and Material 48×48dp minimum touch targets, fixes 100vh clipping with 100dvh plus env(safe-area-inset-*), and ships React/Next.js patterns for bottom navigation, slide-out drawers, pull-to-refresh hooks, and Tailwind breakpoints from sm (640px) through 2xl (1536px). The bundled references/ folder adds keyboard-handling.md, animations.md, and accessibility.md for virtual-keyboard and mobile a11y edge cases. Reach for mobile-ux-optimizer when mobile layouts feel broken, tap targets are too small, or notch/home-indicator padding is missing—not for React Native, PWA service workers, or desktop-only CSS.
- Thumb-zone and tap-target sizing
- Navigation depth reduction
- Form and input friction fixes
- Loading and empty-state polish
- Platform HIG alignment checks
Mobile Ux Optimizer by the numbers
- 349 all-time installs (skills.sh)
- Ranked #731 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/erichowens/some_claude_skills --skill mobile-ux-optimizerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 349 |
|---|---|
| repo stars | ★ 178 |
| Last updated | July 14, 2026 |
| Repository | erichowens/some_claude_skills ↗ |
How do you fix mobile viewport and touch target UX?
Improve tap targets, navigation, and screen flows on existing mobile product surfaces.
Who is it for?
Frontend developers shipping mobile-first React, Next.js, or Tailwind web apps who need concrete touch, viewport, and navigation fixes.
Skip if: Skip mobile-ux-optimizer for native Swift/Kotlin/React Native apps, PWA install/service-worker setup, or general desktop CSS questions.
When should I use this skill?
User mentions mobile UX, touch targets, 100vh/dvh bugs, safe-area notches, bottom navigation, swipe gestures, or responsive breakpoint strategy on web.
What you get
Updated mobile CSS/Tailwind rules, bottom-nav or drawer components, gesture hooks, and a device testing checklist for notch, keyboard, and scroll behavior.
- Mobile navigation components
- Viewport and safe-area CSS fixes
- Device testing checklist
By the numbers
- Specifies 44×44pt and 48×48dp minimum touch targets
- Documents 5 Tailwind breakpoints (sm 640px through 2xl 1536px)
- Includes 3 supplementary reference guides in references/
Files
Mobile-First UX Optimization
Build touch-optimized, performant mobile experiences with proper viewport handling and responsive patterns.
When to Use
✅ USE this skill for:
- Viewport issues (
100vhproblems, 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-nativeorswift-executorskills - 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-expertskill
Core Principles
Mobile-First Means Build Up, Not Down
/* ❌ 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
Apple's Human Interface Guidelines specify 44×44 points as minimum touch target. Google Material suggests 48×48dp.
// 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
Mobile browsers have dynamic toolbars. 100vh includes the URL bar, causing content to be cut off.
/* ❌ 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)
/* 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:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">Tailwind Safe Area Classes
// 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)
// 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)
'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`
| Hook | Purpose |
|---|---|
useSwipe() | Directional swipe detection with configurable threshold |
usePullToRefresh() | Pull-to-refresh with visual feedback and resistance |
Quick usage:
// 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
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
// 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)
// 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
sm: 640px - Large phones (landscape)
md: 768px - Tablets
lg: 1024px - Small laptops
xl: 1280px - Desktops
2xl: 1536px - Large screens// 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)
/* Component responds to its container, not viewport */
@container (min-width: 400px) {
.card { flex-direction: row; }
}<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
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
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
BrowserStack/Real Device Testing
# Expose local dev server to internet
npx localtunnel --port 3000
# or
ngrok http 3000Quick Reference
| Issue | Solution |
|---|---|
| Content cut off at bottom | Use 100dvh instead of 100vh |
| Notch overlaps content | Add pt-safe / pb-safe |
| Touch targets too small | Min 44×44px |
| Scroll locked | Check overflow: hidden on body |
| Keyboard covers input | Use visualViewport API |
| Janky scrolling | Use will-change: transform |
| Double-tap zoom | Add touch-action: manipulation |
References
See /references/ for detailed guides:
keyboard-handling.md- Virtual keyboard and form UXanimations.md- Touch-friendly animationsaccessibility.md- Mobile a11y requirements
Gesture Hooks
Touch gesture handling for mobile interactions.
useSwipe Hook
Directional swipe detection with configurable threshold.
// hooks/useSwipe.ts
'use client';
import { useRef, TouchEvent } from 'react';
interface SwipeConfig {
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
onSwipeUp?: () => void;
onSwipeDown?: () => void;
threshold?: number; // Minimum distance in pixels (default: 50)
}
export function useSwipe(config: SwipeConfig) {
const touchStart = useRef<{ x: number; y: number } | null>(null);
const threshold = config.threshold ?? 50;
const handleTouchStart = (e: TouchEvent) => {
touchStart.current = {
x: e.touches[0].clientX,
y: e.touches[0].clientY,
};
};
const handleTouchEnd = (e: TouchEvent) => {
if (!touchStart.current) return;
const deltaX = e.changedTouches[0].clientX - touchStart.current.x;
const deltaY = e.changedTouches[0].clientY - touchStart.current.y;
// Determine primary direction (larger delta wins)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if (deltaX > threshold) config.onSwipeRight?.();
if (deltaX < -threshold) config.onSwipeLeft?.();
} else {
if (deltaY > threshold) config.onSwipeDown?.();
if (deltaY < -threshold) config.onSwipeUp?.();
}
touchStart.current = null;
};
return { handleTouchStart, handleTouchEnd };
}Usage
function ImageGallery({ images, currentIndex, setCurrentIndex }) {
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: () => setCurrentIndex(i => Math.min(i + 1, images.length - 1)),
onSwipeRight: () => setCurrentIndex(i => Math.max(i - 1, 0)),
threshold: 75, // Require more deliberate swipes
});
return (
<div
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<img src={images[currentIndex]} alt="" />
</div>
);
}Key patterns:
- Only fires when threshold exceeded (prevents accidental triggers)
- Primary direction detection (no diagonal confusion)
- Optional callbacks for each direction
- Refs for touch state (no re-renders during gesture)
---
usePullToRefresh Hook
Pull-to-refresh with visual feedback and resistance.
// hooks/usePullToRefresh.ts
'use client';
import { useState, useRef } from 'react';
export function usePullToRefresh(onRefresh: () => Promise<void>) {
const [isRefreshing, setIsRefreshing] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const startY = useRef<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const threshold = 80; // Pixels to pull before triggering
const handleTouchStart = (e: React.TouchEvent) => {
// Only start if at top of scroll container
if (containerRef.current?.scrollTop === 0) {
startY.current = e.touches[0].clientY;
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (startY.current === null || isRefreshing) return;
const currentY = e.touches[0].clientY;
const distance = Math.max(0, currentY - startY.current);
// Apply resistance (0.5x) and cap at 120px
setPullDistance(Math.min(distance * 0.5, 120));
};
const handleTouchEnd = async () => {
if (pullDistance >= threshold && !isRefreshing) {
setIsRefreshing(true);
await onRefresh();
setIsRefreshing(false);
}
setPullDistance(0);
startY.current = null;
};
return {
containerRef,
pullDistance,
isRefreshing,
handlers: {
onTouchStart: handleTouchStart,
onTouchMove: handleTouchMove,
onTouchEnd: handleTouchEnd,
},
};
}Usage with Visual Indicator
function RefreshableList({ items, onRefresh }) {
const { containerRef, pullDistance, isRefreshing, handlers } =
usePullToRefresh(onRefresh);
return (
<div
ref={containerRef}
{...handlers}
className="h-full overflow-y-auto"
>
{/* Pull indicator */}
<div
className="flex justify-center items-center overflow-hidden transition-all"
style={{ height: pullDistance }}
>
{isRefreshing ? (
<Spinner className="w-6 h-6 animate-spin" />
) : (
<ArrowDown
className="w-6 h-6 transition-transform"
style={{
transform: `rotate(${Math.min(pullDistance / threshold, 1) * 180}deg)`
}}
/>
)}
</div>
{/* Content */}
{items.map(item => <ItemCard key={item.id} item={item} />)}
</div>
);
}Key patterns:
- Only activates at scroll top (doesn't hijack normal scrolling)
- 0.5x resistance feels natural (not 1:1 with finger movement)
- 120px cap prevents over-pull
- Arrow rotates as progress indicator
- Async refresh with loading state
Navigation Components
Mobile navigation patterns optimized for touch and safe areas.
BottomNav Component
Fixed bottom navigation with safe area padding and active state handling.
// 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>
);
}Key patterns:
pb-safefor notch/home indicator clearancemin-h-[56px]exceeds 44px touch target minimum- Active state uses
startsWithfor nested routes - Icons + labels for accessibility
---
Drawer Component
Portal-based slide-out drawer with body scroll lock and keyboard handling.
// components/Drawer.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) {
// Lock body scroll when open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Close on Escape key
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 panel */}
<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
);
}Key patterns:
- Portal renders outside component tree (avoids z-index issues)
- Body scroll lock prevents background scrolling
- Escape key dismissal for accessibility
max-w-[80vw]prevents full-width takeover on tabletspt-safe pb-saferespects notch and home indicator- Backdrop click to close
Animation CSS
Add to your global CSS:
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-left {
animation: slide-in-left 0.2s ease-out;
}Or in Tailwind config:
// tailwind.config.js
module.exports = {
theme: {
extend: {
animation: {
'slide-in-left': 'slide-in-left 0.2s ease-out',
},
keyframes: {
'slide-in-left': {
from: { transform: 'translateX(-100%)' },
to: { transform: 'translateX(0)' },
},
},
},
},
};Related skills
How it compares
Pick mobile-ux-optimizer for responsive web touch and viewport fixes; use react-native or stream-swift skills for native mobile SDK integration.
FAQ
What touch target size does mobile-ux-optimizer recommend?
mobile-ux-optimizer follows Apple Human Interface Guidelines at 44×44 points and Google Material guidance at 48×48dp. The skill ships Tailwind min-h-[44px] min-w-[44px] button and link examples so tap areas meet platform minimums.
Does mobile-ux-optimizer cover native mobile apps?
mobile-ux-optimizer targets mobile-first responsive web and PWA surfaces using CSS, Tailwind, and React/Next.js. The skill explicitly directs native iOS or Android work to react-native or swift-executor skills instead.