mobile-ux-optimizer by erichowens/some_claude_skills
npx skills add https://github.com/erichowens/some_claude_skills --skill mobile-ux-optimizer通过正确的视口处理和响应式模式,构建触控优化、性能卓越的移动端体验。
✅ 在以下场景使用此技能:
100vh 问题、安全区域、刘海屏)❌ 不要在以下场景使用:
react-native 或 swift-executor 技能web-design-expert 技能pwa-expert 技能/* ❌ 反面模式:桌面优先(向下缩放) */
.card { width: 400px; }
@media (max-width: 768px) { .card { width: 100%; } }
/* ✅ 正确做法:移动优先(向上扩展) */
.card { width: 100%; }
@media (min-width: 768px) { .card { width: 400px; } }
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
苹果的人机界面指南规定最小触控目标为 44×44 点。谷歌 Material 设计建议 48×48dp。
// 触控友好的按钮
<button className="min-h-[44px] min-w-[44px] px-4 py-3">
点击我
</button>
// 具有足够内边距的触控友好链接
<a href="/page" className="inline-block py-3 px-4">
链接文本
</a>
dvh 解决方案移动浏览器具有动态工具栏。100vh 包含了地址栏,导致内容被截断。
/* ❌ 反面模式:内容被浏览器 UI 遮挡 */
.full-screen { height: 100vh; }
/* ✅ 正确做法:响应浏览器界面变化 */
.full-screen { height: 100dvh; }
/* 旧版浏览器的回退方案 */
.full-screen {
height: 100vh;
height: 100dvh;
}
/* 处理 iPhone 刘海屏和主屏幕指示条 */
.bottom-nav {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.header {
padding-top: env(safe-area-inset-top, 0);
}
/* 完整的安全区域内边距 */
.safe-container {
padding: env(safe-area-inset-top)
env(safe-area-inset-right)
env(safe-area-inset-bottom)
env(safe-area-inset-left);
}
必需的 meta 标签:
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
// 自定义 Tailwind 工具类(添加到 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)); }
}
// 用法示例
<nav className="fixed bottom-0 pb-safe bg-leather-900">
<BottomNav />
</nav>
// components/BottomNav.tsx
'use client';
import { usePathname } from 'next/navigation';
import Link from 'next/link';
const navItems = [
{ href: '/', icon: HomeIcon, label: '首页' },
{ href: '/meetings', icon: CalendarIcon, label: '会议' },
{ href: '/tools', icon: ToolsIcon, label: '工具' },
{ href: '/my', icon: UserIcon, label: '我的恢复' },
];
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>
);
}
'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) {
// 打开时阻止 body 滚动
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// 按 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">
{/* 背景遮罩 */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* 抽屉 */}
<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
);
}
完整实现见
references/gestures.md
| 钩子 | 用途 |
|---|---|
useSwipe() | 带可配置阈值的定向滑动检测 |
usePullToRefresh() | 带视觉反馈和阻力的下拉刷新 |
快速使用:
// 滑动关闭
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: () => dismiss(),
threshold: 50,
});
// 下拉刷新
const { containerRef, pullDistance, isRefreshing, handlers } =
usePullToRefresh(async () => await refetchData());
import Image from 'next/image';
// 具有正确尺寸的响应式图片
<Image
src="/hero.jpg"
alt="主图"
fill
sizes="(max-width: 768px) 100vw, 50vw"
priority // 用于首屏图片
className="object-cover"
/>
// 懒加载首屏以下的图片
<Image
src="/feature.jpg"
alt="功能"
width={400}
height={300}
loading="lazy"
/>
// 对重型组件进行动态导入
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // 跳过仅限客户端的服务器渲染
});
// 懒加载首屏以下的部分
const Comments = dynamic(() => import('@/components/Comments'));
// 匹配最终内容布局的骨架屏
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>
);
}
// 用法示例
{isLoading ? (
<div className="space-y-4">
{[...Array(5)].map((_, i) => <MeetingCardSkeleton key={i} />)}
</div>
) : (
meetings.map(m => <MeetingCard key={m.id} meeting={m} />)
)}
sm: 640px - 大屏手机(横屏)
md: 768px - 平板电脑
lg: 1024px - 小型笔记本电脑
xl: 1280px - 桌面电脑
2xl: 1536px - 大屏幕
// 移动端:垂直堆叠,平板及以上:并排
<div className="flex flex-col md:flex-row gap-4">
<aside className="w-full md:w-64">侧边栏</aside>
<main className="flex-1">内容</main>
</div>
// 移动端:底部导航,桌面端:侧边栏
<nav className="md:hidden fixed bottom-0 left-0 right-0">
<BottomNav />
</nav>
<aside className="hidden md:block w-64">
<SidebarNav />
</aside>
/* 组件响应其容器,而非视口 */
@container (min-width: 400px) {
.card { flex-direction: row; }
}
<div className="@container">
<div className="flex flex-col @md:flex-row">
{/* 响应父容器宽度 */}
</div>
</div>
# 将本地开发服务器暴露到互联网
npx localtunnel --port 3000
# 或
ngrok http 3000
| 问题 | 解决方案 |
|---|---|
| 底部内容被截断 | 使用 100dvh 替代 100vh |
| 刘海屏遮挡内容 | 添加 pt-safe / pb-safe |
| 触控目标太小 | 最小 44×44 像素 |
| 滚动被锁定 | 检查 body 上的 overflow: hidden |
| 键盘遮挡输入框 | 使用 visualViewport API |
| 滚动卡顿 | 使用 will-change: transform |
| 双击缩放 | 添加 touch-action: manipulation |
查看 /references/ 获取详细指南:
keyboard-handling.md - 虚拟键盘和表单用户体验animations.md - 触控友好的动画accessibility.md - 移动端无障碍要求每周安装量
157
代码仓库
GitHub 星标数
78
首次出现
Jan 23, 2026
安全审计
安装于
opencode139
gemini-cli136
codex131
cursor130
github-copilot123
claude-code119
Build touch-optimized, performant mobile experiences with proper viewport handling and responsive patterns.
✅ USE this skill for:
100vh problems, safe areas, notches)❌ DO NOT use for:
react-native or swift-executor skillsweb-design-expertpwa-expert skill/* ❌ 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; } }
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>
dvh SolutionMobile 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;
}
/* 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">
// 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>
// 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>
);
}
'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
);
}
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());
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"
/>
// 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 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} />)
)}
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>
/* 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>
# Expose local dev server to internet
npx localtunnel --port 3000
# or
ngrok http 3000
| 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 |
See /references/ for detailed guides:
keyboard-handling.md - Virtual keyboard and form UXanimations.md - Touch-friendly animationsaccessibility.md - Mobile a11y requirementsWeekly Installs
157
Repository
GitHub Stars
78
First Seen
Jan 23, 2026
Security Audits
Gen Agent Trust HubPassSocketPassSnykPass
Installed on
opencode139
gemini-cli136
codex131
cursor130
github-copilot123
claude-code119
GSAP时间轴动画教程:创建多步骤序列动画与关键帧控制
3,100 周安装
Swift MCP 服务器生成器 - 快速构建生产级 MCP 服务器,支持多平台
7,700 周安装
开源项目赞助者查找工具 - 一键分析依赖,支持维护者
7,700 周安装
交互式编程助手 | 基于REPL的系统探索与修改工具 | GitHub Copilot增强插件
7,800 周安装
VS Code 扩展本地化工具 - 快速实现多语言支持(vscode-ext-localization)
7,800 周安装
如何创建 llms.txt 文件:为大型语言模型优化仓库导航的完整指南
7,800 周安装
Solidity 智能合约安全指南:防范重入攻击、溢出漏洞与访问控制
7,800 周安装
will-change: transform| Double-tap zoom | Add touch-action: manipulation |