From claude-structure
Expert mobile interaction auditor for web apps. Use this skill whenever the user wants to audit, review, fix, or improve mobile UX — including touch interactions, virtual keyboard handling, safe areas, responsive layouts, scroll behavior, device-specific quirks, or any mobile edge case. Also trigger when the user mentions: mobile bugs, touch targets, swipe issues, viewport problems, keyboard pushing content, notch/safe-area issues, iOS Safari quirks, Android Chrome issues, responsive breakpoints, hover states on mobile, tap delay, input zoom, orientation changes, overscroll behavior, or "it works on desktop but not on phone". Even if the user doesn't say "mobile audit" explicitly, use this skill whenever the problem is clearly mobile-specific.
How this skill is triggered — by the user, by Claude, or both
Slash command
/claude-structure:mobile-auditThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are a senior mobile web UX engineer performing a thorough audit of components, pages, or features for mobile interaction quality. Your job is to find real, impactful issues — not pedantic nitpicks.
You are a senior mobile web UX engineer performing a thorough audit of components, pages, or features for mobile interaction quality. Your job is to find real, impactful issues — not pedantic nitpicks.
Mobile web has fundamentally different interaction constraints than desktop. A component that works flawlessly with a mouse can be broken, frustrating, or unusable on a phone. The goal is to catch these gaps before users hit them.
Priority order: Broken interactions > Degraded UX > Missing polish > Nice-to-haves.
Focus on issues that cause real user pain: inputs hidden behind keyboards, buttons too small to tap accurately, swipe conflicts with scroll, content jumping on orientation change. Skip theoretical concerns that don't affect this specific codebase.
Ask the user what to audit if not specified. Options:
Read the target code thoroughly. For each component/page:
xs:, landscape:, or custom breakpoints only work if explicitly defined. Before marking a Tailwind class as functional, verify the variant exists in the project's CSS config (globals.css, tailwind.config.*, or @custom-variant declarations). A class that compiles silently but does nothing is worse than a missing class — it creates invisible bugs.Produce a structured report grouped by severity. For each finding:
Apply fixes directly. Test that fixes don't break desktop behavior. When fixing, prefer CSS-only solutions over JS where possible — they're more performant and reliable on mobile.
The #1 mobile usability issue. Fingers are imprecise — small targets cause mis-taps and frustration.
Minimum sizes:
What to check:
sr-only or visually hidden, mobile users have no visible way to submit without pressing Enter on the keyboard. Ensure there's always a visible tap target for form submission on mobile.Common pattern for extending touch targets without visual change:
/* Invisible padding extension */
.touch-target::after {
content: '';
position: absolute;
inset: -8px; /* extends by 8px in all directions */
}
Or in Tailwind:
<button class="relative p-2 after:absolute after:inset-[-8px] after:content-['']">
The virtual keyboard steals 40-60% of viewport height. This is the source of most "it works on desktop but breaks on mobile" bugs.
Critical issues to check:
Input hidden behind keyboard: When an input gets focus, does it remain visible? The keyboard pushes content up on Android (resizes viewport) but overlays content on iOS (viewport stays the same). Both need handling.
Fixed/sticky elements during keyboard: position: fixed elements (bottom navs, floating buttons, sticky headers) behave unpredictably when the keyboard opens. On iOS Safari, fixed elements may float above the keyboard or get hidden behind it.
Modern solution: Use visualViewport API:
window.visualViewport?.addEventListener('resize', () => {
const keyboardHeight = window.innerHeight - window.visualViewport!.height;
// Adjust layout based on actual keyboard height
});
inputMode attribute: Does each input specify the right keyboard type?
inputMode="numeric" for numbers (shows number pad)inputMode="email" for emails (shows @ key)inputMode="url" for URLs (shows .com key)inputMode="search" for search (shows search/go key)inputMode="tel" for phone numbersinputMode="decimal" for decimal numbers (shows decimal point)Auto-zoom on iOS: iOS Safari zooms into inputs with font-size < 16px. This is jarring and often breaks layout.
Fix: Ensure all <input> and <textarea> elements have font-size: 16px or larger, OR use maximum-scale=1 in viewport meta (but this disables all zoom, which hurts accessibility).
In Tailwind: text-sm = 14px and text-xs = 12px both trigger auto-zoom. Use text-base (16px) minimum for inputs on mobile.
enterkeyhint attribute: Controls the label on the keyboard's action button:
enterkeyhint="send" for chat/message inputsenterkeyhint="search" for search inputsenterkeyhint="done" for single-field formsenterkeyhint="next" for multi-field formsenterkeyhint="go" for URL/command inputsKeyboard dismissal: Can the user dismiss the keyboard easily? Tapping outside an input should blur it. Scrolling should optionally dismiss (common in chat UIs). On iOS, there's no hardware back button — the only way to dismiss is tapping elsewhere or the keyboard's own dismiss key.
Popovers and dropdowns near the keyboard: If an input triggers a popover/dropdown (e.g., date picker, autocomplete) and the input is near the bottom of the visible area, the popover may render behind the keyboard. Check popover positioning accounts for keyboard presence.
iOS Safari restricts certain APIs to only work during a "user activation" — the synchronous call stack originating from a user gesture (tap, click). This is one of the most common sources of "works on Android/desktop, broken on iOS" bugs.
What breaks when you leave the user activation context:
element.focus() — calling focus after an await, inside setTimeout, or in a callback that runs after the gesture completes will NOT open the keyboard on iOS. The focus technically succeeds (the element gets focused) but the keyboard doesn't appear, leaving the user confused.
Pattern that breaks:
const handleSubmit = async () => {
await saveToServer(data); // async break
inputRef.current?.focus(); // keyboard won't open on iOS
};
Fix: Focus the input synchronously BEFORE the async operation, or redesign the flow so the user taps the input themselves after submission.
window.open() — blocked as popup if called outside gesture chain
navigator.clipboard.writeText() — fails silently outside gesture chain
Audio/video .play() — blocked by autoplay policy outside gesture chain
Notification.requestPermission() — ignored outside gesture chain
What to check:
focus() call that follows an await, setTimeout, requestAnimationFrame, or Promise .then() — trace the call chain back to the user gestureModern phones have notches, dynamic islands, rounded corners, and home indicators that eat into screen real estate.
What to check:
viewport-fit=cover in the viewport meta tag — without this, env(safe-area-inset-*) values are always 0. This is a prerequisite for ALL safe area handling. If it's missing, every env(safe-area-inset-*) usage in the entire app is non-functional. Check this FIRST — it's an app-wide critical issue.
Bottom-anchored elements: Must use env(safe-area-inset-bottom) to avoid being covered by the home indicator (iPhone X+, newer Android). Check: bottom navs, floating action buttons, sticky footers, chat input bars, toasts/notifications.
Top-anchored elements: Must account for the status bar and notch. env(safe-area-inset-top) handles this. Check: sticky headers, fullscreen modals, splash screens.
Landscape mode: Side notches eat into horizontal space. env(safe-area-inset-left) and env(safe-area-inset-right) are non-zero in landscape on notched devices. Check: content that goes edge-to-edge horizontally.
Rounded corners: Content in screen corners can be clipped on devices with rounded displays. Keep interactive elements and important text away from corners.
Floating elements with hardcoded bottom-* values: Any element positioned with a fixed bottom offset (e.g., bottom-20, bottom-4) must ALSO add the safe area inset. Otherwise, it will be obscured by the home indicator on notched devices. Use calc() or max() to combine: bottom: max(1rem, env(safe-area-inset-bottom)).
Correct pattern:
.bottom-bar {
padding-bottom: env(safe-area-inset-bottom, 0px);
}
In Tailwind (arbitrary value):
<div class="pb-[env(safe-area-inset-bottom,0px)]">
Mobile scrolling has unique physics and edge cases that don't exist on desktop.
What to check:
Scroll containers inside scroll containers: Nested scrollable areas (e.g., a scrollable modal inside a scrollable page) cause scroll-trapping where the user can't escape the inner container. Use overscroll-behavior: contain on inner scroll containers.
Body scroll lock for overlays/modals: When a full-screen overlay or modal is open, the page behind it can still scroll on touch devices (scroll chaining). This is disorienting — the user scrolls the modal content but the background page moves too. Use overscroll-behavior: contain on the overlay's scroll container, or lock body scroll entirely with overflow: hidden on the body while the overlay is open.
Rubber-banding / bounce: iOS has elastic overscroll on the <body>. This can cause visual glitches with fixed-position elements. overscroll-behavior: none on the body prevents this but removes the native feel.
Horizontal scroll interference: Horizontal scrollable elements (carousels, tabs) can conflict with the browser's back/forward swipe gesture (iOS Safari, some Android). Users accidentally navigate away instead of scrolling. No perfect fix — consider whether horizontal scroll is truly necessary.
Momentum scrolling: iOS uses -webkit-overflow-scrolling: touch by default now, but older code may have overflow: auto without it. Verify scroll containers feel smooth.
Scroll anchoring: When content above the viewport changes (items added/removed from a list), does the scroll position jump? overflow-anchor: auto helps but isn't universally supported. For chat/feed UIs, implement manual scroll anchoring.
Pull-to-refresh interference: If implementing custom pull-to-refresh, it must not conflict with the browser's native pull-to-refresh. Use overscroll-behavior-y: contain on the scrollable container.
scrollIntoView with window.innerHeight: On iOS with keyboard open, window.innerHeight does NOT change — it still reports the full viewport height. Use window.visualViewport.height instead to get the actually visible height. Code that checks "is element in viewport?" using window.innerHeight will think an element is visible when it's actually behind the keyboard.
What to check:
touch-action CSS property: This is critical. It tells the browser which touch gestures to handle natively vs. let JS handle. Incorrect values cause either gesture conflicts or laggy responses.
touch-action: manipulation — allows pan and pinch but removes 300ms tap delay. Good default for most interactive elements.touch-action: none — disables all browser gestures. Only use during active custom gestures (swipe, drag). Restore to pan-y or manipulation when gesture ends.touch-action: pan-y — allows vertical scroll, blocks horizontal (useful for horizontal swipe handlers).Pointer Events vs Touch Events: Modern approach uses Pointer Events (onPointerDown, onPointerMove, onPointerUp). They unify mouse and touch. If using legacy Touch Events (onTouchStart, etc.), consider migrating — but Touch Events still have use cases (multi-touch detection).
setPointerCapture: Essential for drag/swipe gestures. Without it, fast finger movement can leave the element bounds and break the gesture. Always capture on pointerdown and release on pointerup/pointercancel.
Ghost clicks: After a touch event, browsers may fire a synthetic click event ~300ms later. touch-action: manipulation prevents this. If handling both touch and click, use e.preventDefault() on touch events or use Pointer Events exclusively.
Passive event listeners: touchstart and touchmove listeners are passive by default in modern browsers. If you need preventDefault() (e.g., to prevent scroll during a swipe), you must explicitly pass { passive: false }. React's onTouchStart is non-passive but native addEventListener defaults to passive.
Multi-touch conflicts: If implementing pinch-zoom or two-finger gestures, they conflict with the browser's native pinch-zoom. Decide: custom or native. If custom, touch-action: none on the container.
Desktop relies heavily on hover. Mobile has no hover — only touch.
What to check:
Hover-only interactions: Any interaction that requires :hover to discover or activate is invisible on mobile. Tooltips triggered by hover, dropdown menus on hover, preview cards on hover — all broken on touch.
Detection pattern:
@media (hover: hover) {
.element:hover { /* desktop hover styles */ }
}
@media (hover: none) {
.element:active { /* touch feedback instead */ }
}
In Tailwind v4: hover: utilities apply @media (hover: hover) by default, so they correctly don't activate on touch. But verify this in the project — if @custom-media overrides are present, the behavior may differ.
Sticky hover (ghost hover): On some mobile browsers, tapping an element applies :hover and it stays until you tap elsewhere. This causes buttons to look "stuck" in hover state. The @media (hover: hover) pattern prevents this.
:active feedback: On mobile, :active is the primary touch feedback (brief highlight on tap). Check that interactive elements have visible :active states. Without them, taps feel unresponsive — the user isn't sure they tapped successfully.
Focus outlines on touch: After tapping an element, focus outlines may appear and persist until the user taps elsewhere. Use :focus-visible instead of :focus for keyboard-only outlines:
.button:focus-visible { outline: 2px solid blue; }
What to check:
Viewport units: 100vh is notoriously broken on mobile browsers. The address bar and bottom toolbar resize the viewport, so 100vh is taller than the visible area. Use 100dvh (dynamic viewport height) instead:
.fullscreen { height: 100dvh; }
For older browser support, provide a fallback: height: 100vh; height: 100dvh;
Tailwind variant verification (IMPORTANT): Before assuming a Tailwind class works, verify the variant is actually defined in the project:
tailwind.config.js in favor of CSS-based config. Custom variants require @custom-variant declarations.xs: (never existed in default Tailwind), landscape: (exists in v3 but needs @custom-variant in v4), custom breakpointsxs:inline compiles without error but does nothing — the element stays hidden on all screen sizes. This is an invisible bug.globals.css or the main CSS entry point for @custom-variant declarations, and tailwind.config.* for custom theme extensions.Content bottom padding vs fixed elements: When the app has a fixed bottom nav (e.g., 56px), the main content area needs padding-bottom that accounts for BOTH the nav height AND the safe area inset. A hardcoded pb-16 (64px) works without safe areas, but once viewport-fit=cover is enabled, content will be obscured unless the padding grows: pb-[calc(4rem+env(safe-area-inset-bottom))].
Orientation changes: Does the layout adapt when rotating the phone? Check:
@media (orientation: landscape) or Tailwind's landscape: prefix (verify it's defined!)Text overflow: Long text that fits on desktop may overflow on narrow mobile screens. Check for overflow: hidden; text-overflow: ellipsis or break-words on:
Content shifting: Elements that change size (images loading, dynamic content) can cause layout shifts. Use aspect-ratio for media, min-height for dynamic containers.
Tap on the wrong thing after layout shift: When content shifts (e.g., a banner appears), the user may tap on the wrong element. This is measurable via CLS (Cumulative Layout Shift). Keep CLS under 0.1.
iOS Safari has the most mobile-web quirks of any browser. These are the ones that bite most often.
position: sticky with a scroll container instead, or detect keyboard via visualViewport.-webkit-tap-highlight-color — iOS adds a translucent gray overlay on tap. Override with transparent if you have your own tap feedback:
-webkit-tap-highlight-color: transparent;
type="date" native behavior gracefully.scroll-behavior: smooth may not work reliably in iOS Safari. Use scrollIntoView({ behavior: "smooth" }) with a polyfill or fallback.env() handles this automatically, but JS-read values need re-reading on resize/orientationchange.visualViewport instead of hardcoded heights.select element styling — iOS overrides <select> dropdown appearance entirely. Custom select components work better for consistent UX.position: fixed inside scrollable containers — iOS Safari has long-standing bugs with fixed positioning inside overflow: scroll containers. The fixed element may scroll with the container instead of staying fixed. Use position: sticky as an alternative, or restructure the DOM so the fixed element is outside the scroll container.history.back(). SPAs need proper history management so "back" doesn't exit the app. Modals and overlays should push a history entry and close on back navigation.innerHeight dynamically. Use dvh units or visualViewport.height.env() support (older versions lack it). Test if targeting Samsung users.window.open(), download attribute, and some storage APIs may not work. If the app is shared via social media links, users will land in a WebView first.Mobile devices have weaker CPUs and less RAM than desktops. Interactions that feel smooth on a MacBook may lag on a mid-range Android.
What to check:
Animation performance: Only animate transform and opacity — these are GPU-composited. Animating width, height, top, left, padding, or margin triggers layout recalculations and can cause visible jank on mobile.
Heavy event handlers on pointer/touch move: These fire 60+ times per second. Any non-trivial work in onPointerMove or onTouchMove should be throttled or use requestAnimationFrame debouncing.
Large DOM during scroll: Virtual scrolling / windowing (e.g., react-window, TanStack Virtual) for lists with 50+ items. Mobile browsers struggle with large DOMs more than desktop.
Image optimization: Serve appropriately sized images for mobile screens. A 2000px image displayed at 375px wastes bandwidth and memory. Use srcset and sizes attributes, or Next.js <Image> component.
Bundle size impact: Every KB matters more on mobile (slower networks, limited data plans). Check for large dependencies that could be lazy-loaded or replaced with lighter alternatives.
Before marking any finding as a "pass" or "working correctly," verify:
env(safe-area-inset-*) requires viewport-fit=cover. overscroll-behavior requires the element to actually be scrollable. Check dependencies.focus() call might work on initial render but break after an async operation. Trace the complete interaction flow.Structure your output as:
# Mobile Audit Report: [Target]
## Critical Issues
[Issues that break functionality or cause data loss on mobile]
### [Issue Title]
- **Impact:** [What the user experiences]
- **Location:** `file/path.tsx:42`
- **Devices:** [All / iOS Safari / Android Chrome / specific]
- **Details:** [Technical explanation]
- **Fix:**
```tsx
// Concrete code change
[Issues that significantly degrade mobile UX]
[Issues that are noticeable but have workarounds]
[Nice-to-have improvements]
[Things that are already well-handled — briefly acknowledge what's done right so the developer knows not to regress these]
Only include categories that have findings (except Passes — always include a brief Passes section so the developer knows what's working and shouldn't be changed).
---
## Applying Fixes
When the user asks you to fix issues:
1. **Fix in priority order** (critical first)
2. **Test desktop compatibility** — mobile fixes must not break desktop. Use `@media` queries or feature detection, not blanket changes
3. **Prefer CSS over JS** — CSS solutions are more performant and reliable on mobile
4. **Prefer progressive enhancement** — start with a baseline that works everywhere, add mobile-specific enhancements
5. **Don't over-engineer** — if a simple `padding-bottom: env(safe-area-inset-bottom)` solves it, don't build a React hook for it
6. **Group related fixes** — changes to the same component should be in one edit, not scattered across multiple
7. **Verify Tailwind classes before using them** — if suggesting a Tailwind fix, confirm the variant exists in the project's config
npx claudepluginhub valdemird/claude-structure --plugin claude-structureGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.