关于 web-design
web-design 是一项面向网页界面构建、评审和改进的实践参考技能。内容按结构、视觉设计、交互、无障碍和细节打磨分层组织,汇集多种设计参考的建议。
主要功能
- 01分层安排界面实现优先级
- 02响应式布局与间距建议
- 03字体排版与色彩原则
- 04无障碍和交互状态检查
- 05动效、性能与 CSS 建议
适用场景
- 01规划网页布局
- 02检查响应式表现
- 03审查无障碍与组件状态
- 04打磨动效和视觉细节
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ pascalorg/ skills --skill web-design安装后在 Claude Code 中输入/web-design确认已加载。
详细介绍
Web 设计
一份来自实践者的参考指南,助你打造出色的 Web 界面。内容综合自 Refactoring UI、Tailwind CSS、shadcn/ui、Laws of UX、animations.dev、detail.design、Every Layout、Web Interface Guidelines、jakub.kr、userinterface.wiki(Raphael Salaja)及其他权威来源。
构建、评审或改进 Web 界面时,请使用本指南。
实现优先级
构建或评审 UI 时,请按顺序逐层处理。每一层都依赖上一层——如果布局有问题,此时纠结阴影细节就是白费功夫。
第 1 层:结构(先把这部分做好)
- 语义化 HTML。 使用正确的元素(
<button>、<nav>、<main>,标题按顺序排列)。其他一切都以此为基础。 - 布局。 网格/弹性盒结构、间距比例、内容宽度限制(
max-w-prose、12 列网格)。页面在所有视口尺寸下都能保持完整吗? - 响应式行为。 移动端优先、内在尺寸(
auto-fill网格、clamp()、flex-wrap)。除非有充分理由,否则不要在小屏幕上隐藏内容。 - 排版基础。 字号比例、行高(正文 1.5,标题 1.1-1.25)、行长(
max-width: 65ch)、字号使用rem单位。
第 2 层:视觉系统(设计的骨干)
- 颜色和对比度。 应用配色方案,设置语义化令牌,满足 WCAG AA 对比度要求(文本 4.5:1,UI 3:1)。需要时支持深色模式。
- 视觉层级。 四个文本层级清晰有效(前景色、弱化色、弱化色/70、弱化色/50)。主要操作一目了然。通过眯眼测试。
- 组件状态。 为每个交互元素考虑悬停、可见焦点、激活、禁用、加载、错误和空状态。
- 间距和比例。 外边距 >= 内边距。避免含糊不清的间隙。按钮内边距比例(水平 2x : 垂直 1x)。输入框高度与按钮高度一致。
第 3 层:交互和动效(让界面鲜活起来)
- 键盘和无障碍访问。 焦点样式、Tab 顺序、跳过链接、
aria属性、触控目标(44px+)。仅使用键盘进行测试。 - 过渡效果。 悬停/激活反馈(150ms ease-out)、状态过渡(200-300ms),根据方向使用恰当的缓动(进入用 ease-out,退出用 ease-in)。
- 动画。 入场动画采用错峰效果,退出动画轻微自然,过渡可被中断,遵循
prefers-reduced-motion设置。适当时使用弹簧物理效果。
第 4 层:润色(让设计更精致的最后 10%)
- 阴影和层次。 使用多层阴影,在合适的情况下用阴影代替边框,并保持光源方向一致。深色模式:调整表面亮度,而非使用阴影。
- 视觉微调。 图标侧的按钮内边距、同心圆角、播放按钮偏移、数据使用等宽数字、为图片添加轮廓。
- 微交互。 根据上下文设置图标动画、错峰入场时添加模糊效果、复制到剪贴板的反馈、乐观更新。
- 防御式 CSS。 为弹性子项设置
min-width: 0,使用overflow-wrap: break-word、scrollbar-gutter: stable、文本截断和安全区域内边距。 - 最终检查。 眯眼测试、灰度测试、替换测试、“人类会交付这个设计吗?”测试。避免 AI 垃圾设计(无端的渐变、千篇一律的指标卡片、所有内容都居中)。
经验法则: 如果布局在 768px 时就会崩坏,就别再纠结阴影不透明度了,回到第 1 层。
目录
- 布局与间距
- 排版
- 颜色
- 阴影与层次
- 视觉层级
- 动画与动效
- 组件
- 响应式设计
- 无障碍
- 性能
- 用户体验心理学
- 设计令牌
- 打磨与工艺
- 微文案与 UX 写作
- 避免 AI 味
- 进阶工艺
- 防御式 CSS
- 现代 CSS 重置
- 预测性预取
- 音频反馈与声音设计
1. 布局与间距
间距比例
使用以基础单位为根基的一致数学比例。标准基础单位为 4px(0.25rem)。所有间距值都应为该基础单位的倍数。
| 令牌 | 值 | 用途 |
|---|---|---|
| 0.5 | 2px | 极细间隙、图标内边距 |
| 1 | 4px | 紧凑的行内间距 |
| 1.5 | 6px | 小型组件的内部内边距 |
| 2 | 8px | 相关项目之间的默认间距 |
| 3 | 12px | 紧凑的卡片内边距 |
| 4 | 16px | 标准卡片/区块内边距 |
| 5 | 20px | 宽裕的内边距 |
| 6 | 24px | 区块内边距 |
| 8 | 32px | 区块间距 |
| 10 | 40px | 大区块间距 |
| 12 | 48px | 页面区块间距 |
| 16 | 64px | 页面主要分区 |
| 20 | 80px | 主视觉区间距 |
| 24 | 96px | 大型主视觉区间距 |
间距规则
- 外部内边距 >= 内部内边距。 卡片的外边距必须等于或大于其内部内边距。内部元素彼此之间的关系应比它们与外部元素之间的关系更紧密。
- 距离近表示关系密切。 距离更近的元素会被认为彼此相关(格式塔接近律)。有意识地运用间距进行分组或区分。
- 消除含糊的间距。 如果两个元素之间的间隙可能属于任一元素,那么这个间隙就是含糊的。通过不对称间距明确元素关系。
- 消除无效区域。 在子元素上使用内边距,而不是在容器上使用外边距。交互项目之间的每个像素都应可点击。
- 使用一致的增量。 不要随意选取数值,应限制在比例范围内。三个相近的间距(14px、16px、18px)看起来像是失误——选一个即可。
布局原语
以下 CSS 模式无需媒体查询即可创建响应式布局:
Stack(垂直堆叠) —— 以一致间距排列的垂直流:
.stack > * + * { margin-block-start: var(--space); }
Cluster(水平簇) —— 带间距的水平换行布局:
.cluster { display: flex; flex-wrap: wrap; gap: var(--space); }
Sidebar(侧边栏) —— 两列布局,其中一列固定:
.sidebar { display: flex; flex-wrap: wrap; gap: var(--space); }
.sidebar > :first-child { flex-basis: 20rem; flex-grow: 1; }
.sidebar > :last-child { flex-basis: 0; flex-grow: 999; min-inline-size: 60%; }
Grid(auto-fill) —— 无需断点的响应式列布局:
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 250px), 1fr)); gap: var(--space); }
Center(居中) —— 限制宽度并居中:
.center { max-inline-size: var(--measure); margin-inline: auto; padding-inline: var(--space); }
Grid 与 Flex 布局指南
- 二维布局(同时包含行和列)使用 CSS Grid。
- 一维布局(单行或单列)使用 Flexbox。
- 在 Grid 中优先使用
fr单位而非百分比——fr会在扣除间隙后分配空间,避免溢出。 - 使用
gap代替项目之间的外边距。 - 12 列网格提供最大的灵活性(可被 1、2、3、4、6 整除)。
- 在 Flex 子项上设置
min-width: 0,以防内容溢出。 - 在 Grid 布局中的粘性侧边栏元素上使用
align-self: start。
实践:Tailwind 布局模式
带侧边栏的页面框架:
<div className="flex min-h-screen">
<aside className="hidden lg:flex w-64 flex-col border-r bg-muted/40 p-4">
<nav className="flex flex-col gap-1">{/* nav items */}</nav>
</aside>
<main className="flex-1 p-6">
<div className="mx-auto max-w-4xl space-y-8">{children}</div>
</main>
</div>
无需断点即可自适应的卡片网格:
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-4">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
具有一致垂直节奏的内容区块:
<section className="space-y-6 py-12">
<h2 className="text-2xl font-semibold tracking-tight">Features</h2>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map(f => <FeatureCard key={f.id} {...f} />)}
</div>
</section>
具有恰当间距层级(外部 > 内部)的卡片:
<div className="rounded-lg border bg-card p-6"> {/* outer: p-6 */}
<div className="space-y-4"> {/* inner gap: 4 */}
<h3 className="font-semibold leading-tight">Title</h3>
<p className="text-sm text-muted-foreground">Description text here.</p>
</div>
</div>
2. 排版
字号比例
使用数学比例生成协调的字号。根据使用场景选择比例:
| 比例 | 名称 | 适用场景 |
|---|---|---|
| 1.067 | 小二度 | 密集型 UI、仪表板 |
| 1.125 | 大二度 | 紧凑型界面 |
| 1.200 | 小三度 | 通用场景(推荐默认值) |
| 1.250 | 大三度 | 内容密集型网站 |
| 1.333 | 纯四度 | 营销、编辑出版 |
| 1.414 | 增四度 | 强烈醒目的展示 |
| 1.500 | 纯五度 | 戏剧性的层次结构 |
| 1.618 | 黄金比例 | 高冲击力的落地页 |
字号规则
- 正文最小字号:16px(1rem)。 这是浏览器默认值。主要阅读内容切勿使用更小的字号。
- 字号使用
rem。 这样既能响应浏览器缩放,也能尊重用户的默认字号偏好。像素值会阻止用户调整文字大小。 - 媒体查询使用
rem。 用户调大默认字号时,基于 rem 的断点会在更宽的屏幕上触发移动端布局,为用户提供更多空间。 - 内边距、边框和装饰元素使用
px。 这些不应随文字大小缩放。 - 垂直外边距使用
rem。 段落间距应随文字大小增加。
流式排版
使用 clamp() 实现响应式字号,使其在断点之间平滑缩放:
/* Heading: 32px on small screens, 48px on large, fluid between */
font-size: clamp(2rem, 1.5rem + 2vw, 3rem);
定义小屏幕比例(例如在 320px 时为 1.2x)和大屏幕比例(例如在 1500px 时为 1.333x)。浏览器会在两者之间进行插值。
行高
- 正文:1.5。 这符合 WCAG 标准,并能提升所有用户的阅读体验。
- 标题:1.1 - 1.25。 字号越大,行距应越紧。
- 文字越大,所需行高越小。 行高应按反向比例缩放。
- 标题和按钮:1.1 是合适的默认值。
行长
- 最佳:60-75 个字符(每行约 8-10 个词)。
- 最多:80 个字符。 超出后,读者容易看丢位置。
- 在内容容器上使用
max-width: 65ch来限制行长。
字间距
- 大号文字:减小字间距。 字号越大,字符间所需的空间越小。
- 小号文字:略微增加字间距。
- 全大写文字:增加 +0.05 到 +0.1em 的字间距。 大写字母之间容易显得拥挤,需要留出额外空间。
- 在表格和计时器中使用
font-variant-numeric: tabular-nums,确保数字宽度一致。
文本换行
/* Balanced line breaks for headings */
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
/* Avoid orphans in paragraphs */
p { text-wrap: pretty; }
/* Prevent overflow from long words/URLs */
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
字体渲染
body { -webkit-font-smoothing: antialiased; }
html { text-rendering: optimizeLegibility; -webkit-text-size-adjust: 100%; }
字重
- 切勿使用低于 400 的字重——在大多数屏幕上会难以辨认。
- 中等标题使用 500-600。
- 强调内容使用 700。
- 避免字重变化导致布局偏移: 使用带粗体文本的隐藏
::after伪元素预留粗体文本宽度,避免切换活动状态时发生抖动。
OpenType 特性
现代字体都带有 OpenType 特性,可显著提升排版质量。请有针对性地启用这些特性:
| 特性 | CSS | 用途 |
|---|---|---|
| 等宽数字 | font-variant-numeric: tabular-nums | 表格、仪表板、定价、计时器——等宽数字可在列中对齐 |
| 老式数字 | font-variant-numeric: oldstyle-nums | 正文/段落——带有升部/降部的数字与小写字母更协调 |
| 斜线零 | font-variant-numeric: slashed-zero | 代码周边界面、ID、错误代码——区分 0 和 O |
| 正规分数 | font-variant-numeric: diagonal-fractions | 食谱、规格说明——将 1/2 转换为排版分数 |
| 上下文替代字形 | font-feature-settings: "calt" 1 | 通常默认启用——保持开启,以便智能调整字形 |
| 消歧义字形集 | font-feature-settings: "ss02" | 代码界面——区分 I/l/1 和 0/O(取决于字体) |
/* Data display: aligned, disambiguated */
.data { font-variant-numeric: tabular-nums slashed-zero; }
/* Prose: numbers that blend with text */
.prose { font-variant-numeric: oldstyle-nums; }
/* Code-adjacent UI */
.code-ui { font-variant-numeric: tabular-nums slashed-zero; font-feature-settings: "ss02"; }
font-optical-sizing: auto——保持默认值。字体会根据字号调整字形形状(小字号时笔画更粗,大字号时细节更精细)。font-synthesis: none——禁用此功能,避免字体缺少相应字重时,浏览器生成仿粗体/斜体。这样就必须加载合适的字体文件。- 可变字体接受 100-900 之间的任意字重,而不仅限于标准档位。使用 450 或 550 等精确值,即可细化层级,无需加载额外文件。
- **
text-decoration-skip-ink: auto**是默认值,可正确避开降部。添加text-underline-offset: 3px,让下划线移至降部下方,使链接更清晰。
字体搭配
- **最多使用两种字体。**一种用于标题,一种用于正文。第二种字体应强化设计理念。
- 代码可使用第三种等宽字体。
- 如果只使用一种字体,则通过字重、字号和颜色来区分层级。
实践应用:Next.js + Tailwind Typography
在 app/layout.tsx 中设置字体:
import { Inter, JetBrains_Mono } from "next/font/google"
const sans = Inter({ subsets: ["latin"], variable: "--font-sans" })
const mono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-mono" })
export default function RootLayout({ children }) {
return (
<html lang="en" className={`${sans.variable} ${mono.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
)
}
使用中的 Tailwind 字体比例(shadcn 约定):
{/* Page heading */}
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">Dashboard</h1>
{/* Section heading */}
<h2 className="text-2xl font-semibold tracking-tight">Recent Activity</h2>
{/* Card title */}
<h3 className="font-semibold leading-tight">Monthly Revenue</h3>
{/* Body text with constrained width */}
<p className="max-w-prose text-muted-foreground">
Your revenue increased 12% compared to last month.
</p>
{/* Small label */}
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status
</span>
{/* Tabular numbers in a data display */}
<td className="tabular-nums text-right font-medium">$12,450.00</td>
3. 颜色
颜色系统结构
实用的调色板需要包含三类颜色:
- **中性色(灰色):**8-10 个色阶。用于文字、背景、面板、表单控件——几乎无处不在。
- **主色:**1-2 种颜色,用于主要操作、导航和品牌标识。每种颜色包含 5-10 个色阶。
- **强调色/语义色:**红色(破坏性操作/错误)、黄色/琥珀色(警告)、绿色/翠绿色(成功)、蓝色(信息)。每种颜色包含 5-10 个色阶。
9 色阶系统
为每种颜色定义 9 个色阶,编号为 50-900:
| 色阶 | 用途 |
|---|---|
| 50 | 微妙的染色背景 |
| 100 | 悬停背景、浅色填充 |
| 200 | 激活背景、徽标 |
| 300 | 边框、环形边框 |
| 400 | 占位文本、禁用状态 |
| 500 | 主按钮、图标、链接 |
| 600 | 主按钮的悬停状态 |
| 700 | 激活/按下状态 |
| 800 | 浅色背景上的标题文字 |
| 900 | 浅色背景上的正文文字 |
| 950 | 对比度最高时使用的最深色阶 |
颜色格式
- 优先使用 OKLCH作为颜色格式。它具有感知均匀性(亮度相同的颜色看起来确实同样明亮),所有现代浏览器都支持,并且 Tailwind v4 和 shadcn/ui 也在使用。格式:
oklch(lightness chroma hue)。 - **OKLCH 工具不可用时,HSL 仍是不错的替代方案。**它直观易懂:调整亮度可创建不同色阶,调整饱和度可创建不同色调。
- 将颜色通道存储为 CSS 变量,以获得最大的灵活性:
:root {
/* OKLCH approach (preferred) */
--primary: oklch(62% 0.21 260);
/* HSL approach (alternative) */
--primary-h: 221;
--primary-s: 72%;
--primary-l: 62%;
--primary-hsl: hsl(var(--primary-h) var(--primary-s) var(--primary-l));
}
颜色规则
- 使用近黑色和近白色,而非纯黑和纯白。 在纯白(#fff)上使用纯黑(#000)会产生令人不适的对比。改用
#0a0a0a/#111和#fafafa/#f5f5f5。 - 让中性色带有色彩。 在灰色调中加入少许主色调(在 HSB 中将饱和度保持在 5% 以下),让调色板更加协调。
- 保持色温一致。 中性色要么统一偏暖,要么统一偏冷,不要混用。
- 颜色的区别应体现在明度上,而不只是色相上。 明度相同的两种颜色会在视觉上相互争抢。通过改变明度来形成区分。
- 为鲜艳的变体调整色相。 创建浅色时,将色相朝更明亮的基准色(黄色、青色)偏移;创建深色时,则朝更深的基准色(蓝色、紫色)偏移。这样可以避免颜色显得褪色、粉化。
- 不要只靠颜色传达含义。 为了保证无障碍性,始终搭配文字、图标或图案。
- 使用
color-mix()创建动态变体:
background: color-mix(in oklch, var(--primary), transparent 95%);
深色模式
- 交换浅色和深色的值(900 变为前景色,50 变为背景色)。
- 不要只做反转——深色背景需要降低对比度。容器亮度与背景的差异最多为 12%。
- 避免在深色背景上使用纯白文字。改用近白色(例如
#e5e5e5)。 - 阴影在深色模式下效果不佳——改用低调的边框或较亮的凸起表面。
- 默认遵循
prefers-color-scheme。除非确有需要,否则不要添加主题切换开关。
实践:shadcn/ui 主题配置
app/globals.css ——语义颜色令牌(shadcn/ui 模式):
@layer base {
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.965 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.965 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.965 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--border: oklch(0.269 0 0);
}
}
使用 next-themes 切换深色模式(仅在用户需要手动控制时添加;默认遵循系统偏好):
// app/layout.tsx
import { ThemeProvider } from "next-themes"
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
</ThemeProvider>
</body>
</html>
)
}
// components/theme-toggle.tsx
"use client"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import { Moon, Sun } from "lucide-react"
export function ThemeToggle() {
const { setTheme, theme } = useTheme()
return (
<Button variant="ghost" size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-[transform,opacity] dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-[transform,opacity] dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
)
}
4. 阴影与层次
阴影原则
- 使用统一的光源。 所有阴影都应采用相同的角度比例(通常位于上方并略偏左)。页面上的每个阴影都必须使用相同的偏移比例。
- 映射层级。 元素越凸起:偏移越大、模糊范围越广、不透明度越低。这能模拟真实光线的效果。
- 叠加多层阴影以增加真实感。 单个
box-shadow看起来会很平。将 2-5 层阴影叠加,并让各层数值按几何级数递增。(为便于阅读,以下阴影颜色使用 HSL;任何颜色格式都适用。)
/* Low elevation */
box-shadow: 0 1px 2px hsl(var(--shadow-color) / 0.3);
/* Medium elevation */
box-shadow:
0 1px 2px hsl(var(--shadow-color) / 0.2),
0 2px 4px hsl(var(--shadow-color) / 0.2),
0 4px 8px hsl(var(--shadow-color) / 0.2);
/* High elevation */
box-shadow:
0 1px 2px hsl(var(--shadow-color) / 0.12),
0 2px 4px hsl(var(--shadow-color) / 0.12),
0 4px 8px hsl(var(--shadow-color) / 0.12),
0 8px 16px hsl(var(--shadow-color) / 0.12),
0 16px 32px hsl(var(--shadow-color) / 0.12);
- 让阴影颜色与环境相匹配。 不要使用纯黑或纯灰阴影——应为阴影加入背景色相,让层次显得鲜活、自然。
- 阴影模糊半径 = 阴影距离的 2 倍。 垂直偏移 4px 时,搭配 8px 的模糊半径。
- 靠近用户的元素看起来更亮。 模拟现实光照:靠近光源的表面会更明亮。
- 避免在深色界面中使用阴影。 阴影缺乏视觉逻辑。改用低调的边框或表面明度变化。
- 不要混用层次表现手法。 选择一种方法(阴影、边框或表面明度),并始终保持一致。
- 不要直接为多层阴影添加动画。 这会消耗过多资源。改用伪元素,在两个阴影状态之间为
opacity添加动画。
圆角半径
- 正确嵌套圆角半径。 内层半径 = 外层半径 - 元素间距。如果外层为 12px,内边距为 8px,则内层应为 4px。
- 容器边框需要双重对比。 边框必须能与容器背景和页面背景清晰区分。
用阴影代替边框
轻微叠加的 box-shadow 比实心边框更能增加层次感,还能通过透明度适配任意背景:
/* Multi-shadow composition — replaces border with soft depth */
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.06),
0px 1px 2px -1px rgba(0, 0, 0, 0.06),
0px 2px 4px 0px rgba(0, 0, 0, 0.04);
/* Hover: slightly increase opacity for lift effect */
box-shadow:
0px 0px 0px 1px rgba(0, 0, 0, 0.08),
0px 1px 2px -1px rgba(0, 0, 0, 0.08),
0px 2px 4px 0px rgba(0, 0, 0, 0.06);
- 第一层(
0px 0px 0px 1px)可代替边框——零模糊扩展会形成清晰的 1px 边缘。 - 适用于图片背景和颜色背景,不会像实心
border-color那样产生冲突。 - 悬停时为
box-shadow添加过渡,以呈现交互式抬升效果。使用transition: box-shadow 150ms ease(或 Tailwind 的transition-shadow)。
图像描边
为图像添加 1px 描边,以获得一致的视觉框架和层次感:
img {
outline: 1px solid rgba(0, 0, 0, 0.1);
outline-offset: -1px;
}
/* Dark mode */
@media (prefers-color-scheme: dark) {
img { outline-color: rgba(255, 255, 255, 0.1); }
}
这比内嵌环形效果更简单,而且可直接用于任何 <img>。使用 outline-offset: -1px,让描边位于图像边界内侧。
实践应用:Tailwind 阴影与层次感
Tailwind 的立体效果类与使用场景的对应关系:
{/* Flat surface — default cards */}
<div className="rounded-lg border bg-card shadow-sm">
{/* Raised — hover state, dropdown trigger */}
<div className="rounded-lg border bg-card shadow-md hover:shadow-lg transition-shadow">
{/* Floating — dropdowns, popovers, command palette */}
<div className="rounded-xl border bg-popover shadow-xl">
{/* Dialog/modal backdrop + elevated panel */}
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm">
<div className="rounded-xl border bg-card shadow-2xl">
用阴影替代边框(悬停时抬起的卡片):
<div className="rounded-lg bg-card p-4
shadow-[0px_0px_0px_1px_rgba(0,0,0,0.06),0px_1px_2px_-1px_rgba(0,0,0,0.06),0px_2px_4px_0px_rgba(0,0,0,0.04)]
hover:shadow-[0px_0px_0px_1px_rgba(0,0,0,0.08),0px_1px_2px_-1px_rgba(0,0,0,0.08),0px_2px_4px_0px_rgba(0,0,0,0.06)]
transition-shadow">
<h3 className="font-semibold">No border needed</h3>
<p className="text-sm text-muted-foreground">Shadow provides the edge.</p>
</div>
带内嵌描边的图像(简单方案):
<img
src={src} alt={alt}
className="rounded-lg outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
/>
嵌套圆角(带内嵌图像的卡片):
{/* Outer: rounded-xl (12px), padding: p-2 (8px), so inner = 12-8 = 4px = rounded-sm */}
<div className="rounded-xl border bg-card p-2">
<img className="rounded-sm w-full" src={src} alt={alt} />
<div className="p-3">
<h3 className="font-semibold">Title</h3>
</div>
</div>
5. 视觉层次
核心原则
- 大小、字重和颜色传达重要性。 主要内容更大、更粗、对比度更高;次要内容更小、更浅、对比度更低。
- 弱化次要内容,而非过度强调主要内容。 降低辅助元素的视觉存在感,比让所有内容都更醒目更有效。
- 语义层次 != 视觉层次。
<h2>不一定要呈现为大标题。应根据内容在布局中的作用来设置样式,而非依据其 HTML 标签。 - 减少对标签的依赖。 上下文能清晰表达含义时,标签就是多余的。“[email protected]” 不需要 “Email:” 标签——格式本身就能传达信息。
- 将字重和对比度作为独立调节手段。 深色粗体文本和灰色常规字重文本处于不同的层次。结合大小、字重和颜色,可以获得三种独立的层次控制手段。
- 重要元素使用高对比度,结构性元素使用低对比度。 将注意力引向操作和内容,而非装饰元素和边框。
视觉权重顺序
- 按视觉权重从高到低排列一组元素。最重的元素放在外侧边缘。
- 减少边框。使用间距、背景色差异或阴影替代边框,实现更清爽的分隔。
- 强调边框(3-5px) 放在卡片或提示框的一侧,既能增加视觉趣味和层次,又不会造成过于强烈的分隔。
对齐
- 让每个元素都与其他元素对齐。 对齐能表明元素之间的关联,也能体现布局是经过设计的。
- 优先采用视觉对齐,而非数学对齐。 三角形(播放按钮、箭头)的视觉中心与几何中心并不相同。相信自己的眼睛。
- 测量高对比度点之间的间距。 眼睛依据对比度判断元素边缘,而非边界框。
四级文本层次
每个界面都需要恰好四个文本层级。更多会显得杂乱,更少则会显得平淡:
{/* Level 1: Primary — the main content the user came to see */}
<p className="text-foreground">Invoice #1234 — $2,400.00</p>
{/* Level 2: Secondary — supporting info that adds context */}
<p className="text-muted-foreground">Due March 15, 2026</p>
{/* Level 3: Tertiary — metadata, timestamps, IDs */}
<span className="text-xs text-muted-foreground/70">Created 2 days ago</span>
{/* Level 4: Muted — disabled, placeholder, non-essential */}
<span className="text-xs text-muted-foreground/50">Optional</span>
表面层次(深色模式)
在深色模式下,阴影不可见。应通过亮度变化来营造层次。每个更高的层级亮度提高几个百分点:
{/* Level 0: Page background */}
<div className="bg-background"> {/* darkest */}
{/* Level 1: Card surface */}
<div className="bg-card"> {/* slightly lighter */}
{/* Level 2: Dropdown / popover on top of card */}
<div className="bg-popover"> {/* lighter still */}
</div>
</div>
</div>
各层级之间的亮度差异应当细微(OKLCH 亮度相差 2-5%)。差异过大会让界面分裂成彼此割裂的“世界”。
质量检查
- 眯眼测试。 眯起眼睛观察页面。层次应当依然清晰可见——标题与正文有区别,主要操作与次要操作也有区别。
- 替换测试。 如果将字体替换为系统字体,设计是否依然成立?如果不能,说明层次依赖的是字体,而非结构。
- 灰度测试。 转换为灰度。如果原本不同的元素现在融为一体,说明设计只依赖色相(这会造成无障碍问题)。
6. 动画与动效
何时使用动画
- 反馈: 确认系统已识别某项操作(按下按钮、提交表单、切换开关)。
- 状态转换: 表明界面模式或视图发生变化。
- 空间导航: 帮助用户了解自己在层级结构中的位置(缩放 = 深度,滑动 = 横向移动)。
- 操作提示: 展示如何与元素交互(拖动方向、可滑动区域)。
不要使用动画的场景
- 高频交互。 每天要执行多次的操作应当即时响应——不要添加动画。
- 由键盘触发的操作。 这类操作应当立即响应,并让人感觉与输入紧密相连。
- 性能受影响时。 卡顿的动画还不如没有动画。要么达到 60fps,要么就不要用。
- 每天重复使用的元素。 一天中反复看到的动画会让人厌烦。
- 多余的动效。 如果动画不能让交互更清晰,也不能解释功能,就将其移除。
缓动函数
| 缓动 | 适用场景 |
|---|---|
ease-out | 元素进入屏幕、出现或展开。默认选择。 |
ease-in-out | 已显示在屏幕上的元素移动时。 |
ease | 悬停效果、颜色过渡。 |
linear | 仅用于连续或循环动画(加载指示器、进度填充)。 |
ease-in | 元素离开屏幕时。对于仍留在屏幕上的元素,切勿使用。 |
强烈建议使用自定义曲线,而非内置 CSS 缓动函数。生产环境中的曲线示例:cubic-bezier(0.32, 0.72, 0, 1)(iOS 抽屉行为,时长为 500ms)。
时长指南
| 场景 | 时长 |
|---|---|
| 悬停效果 | 100-150ms |
| 按钮按下反馈 | ~160ms |
| 工具提示进入/退出 | ~125ms |
| 下拉菜单/选择器打开 | ~180ms |
| 常规 UI 过渡 | 150-300ms |
| Toast 通知 | ~400ms |
| 抽屉/底部面板打开 | ~500ms |
| 按住以确认(破坏性操作) | 2000ms(安全性) |
| 松开按键反馈 | 200ms |
- 安全场景采用非对称时长: 破坏性操作使用缓慢、可预测的时长,并配以快速的松开反馈。
- 进入 ≠ 退出。 区分进入和退出动画。进入应当果断;退出应当自然。
- 退出动画应比进入动画更含蓄。 退出时减少位移——使用固定的小幅位移(例如
y: -12px),而不是完整距离(例如y: calc(-100% - 4px))。退出的元素只需有足够的动效来提示方向,无需完整的离场动画。退出时添加轻微的blur(4px),让消失效果更柔和。
动画技巧
按下时缩放: 在 :active 状态下使用 scale(0.97),提供即时的触感反馈。
缩放起点绝不能为 0。 从 scale(0.9) 或更大值开始。即使瘪掉的气球也有可见的形状。
根据原点显示弹出层: 使用 transform-origin 从触发点缩放,而不是从中心缩放。
工具提示延迟模式: 第一个工具提示延迟显示。悬停期间,后续工具提示立即显示。
用模糊效果补救: 如果调整缓动后感觉仍不对,可在过渡期间添加 filter: blur(2px)。
使用 clip-path 揭示内容: 这种方式可由硬件加速,且不会导致布局偏移。使用 clip-path: inset(0 0 100% 0) 过渡到 inset(0 0 0 0)。
让动画可中断。 CSS 过渡会插值到最新状态,并可在动作进行中被中断——将其用于交互(悬停、切换、拖动)。CSS 关键帧动画会按固定时间线运行,无法重新设定目标——将其用于只播放一次的分阶段序列(页面加载进入效果、庆祝效果)。用户在交互过程中可能会改变意图;可中断的动画更显灵敏,不可中断的动画则会让人觉得出了问题。
根据场景为图标添加动画。 图标根据条件出现或消失时(例如,勾选标记替换复制图标),应同时为 opacity、scale 和 blur 添加动画,平滑过渡,而不是瞬间切换。使用弹簧物理效果或较短的过渡(150-200ms)。这适用于任何会根据状态切换显示或隐藏的元素。
舞台调度与编排(选自迪士尼的 12 条原则)
- 每次只设置一个焦点。 同一时间只能有一个元素进行显著动画。如果两个元素同时移动,用户的视线就不知道该看哪里。
- 上下文菜单仅在退出时播放动画,进入时不播放。 菜单应立即出现,并以动画方式消失。
- 挤压与拉伸的缩放范围:0.95-1.05。 超出此范围在 UI 中会显得滑稽。
- 交错延迟:每项最多 50ms。 更长的交错会让人觉得迟缓。对于包含 5 项的列表:0ms、50ms、100ms、150ms、200ms。
- 拆分并交错播放,以增强效果。 不要一次性为整个区块播放动画,而应将其拆分为更小的部分(分区、行,甚至单词),分别播放动画。初始状态可组合使用
opacity、blur(4-5px)和translateY(6-8px)。使用 CSS 自定义属性,方便控制交错效果:animation-delay: calc(var(--stagger-delay, 80ms) * var(--stagger-index, 0))。细分粒度分为三个层级:容器级(简单)、分区级(均衡)、单词级(效果显著)。 - 为叠加层的背景调暗。 暗化的背景能将注意力引向前景元素。
- 使用弹簧物理效果实现弹跳后稳定的效果。 不要用缓动曲线伪造。Framer Motion 的
type: "spring"搭配stiffness: 300, damping: 20是不错的起点。
可安全添加动画的属性
transform(平移、旋转、缩放)——由 GPU 加速opacity——由 GPU 加速clip-path——由 GPU 加速
应避免添加动画的属性
width、height、padding、margin——会触发布局重新计算top、left、right、bottom——会触发布局重新计算background-color——会触发重绘(开销较大)
性能排序
- CSS 动画/过渡(最佳——在主线程之外运行)
- Web Animations API(良好——主线程拥堵时仍能运行)
- JavaScript
requestAnimationFrame(最差——浏览器繁忙时会延迟)
无障碍
始终遵循 prefers-reduced-motion:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
实践示例:Tailwind + Framer Motion
带按压反馈的按钮(纯 CSS,使用 Tailwind)。 持续时间符合约 160ms 的建议(Tailwind 中最接近的值:duration-150):
<button className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground
transition-[background-color,transform] duration-150 ease-out
hover:bg-primary/90
active:scale-[0.97]">
Save Changes
</button>
使用 Framer Motion + shadcn 的动画对话框:
"use client"
import { motion, AnimatePresence } from "motion/react"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
export function AnimatedDialog({ open, onOpenChange, children }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<AnimatePresence>
{open && (
<DialogContent forceMount asChild>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: [0.32, 0.72, 0, 1] }}
>
{children}
</motion.div>
</DialogContent>
)}
</AnimatePresence>
</Dialog>
)
}
交错显示的列表:
import { motion } from "motion/react"
export function StaggeredList({ items }) {
return (
<ul className="space-y-2">
{items.map((item, i) => (
<motion.li key={item.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05, duration: 0.25, ease: "easeOut" }}
className="rounded-lg border bg-card p-4"
>
{item.label}
</motion.li>
))}
</ul>
)
}
带模糊效果的交错显示(纯 CSS,效果更显著):
@keyframes stagger-in {
from {
opacity: 0;
filter: blur(5px);
transform: translateY(8px);
}
}
.stagger-item {
animation: stagger-in 800ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
animation-delay: calc(var(--stagger-index, 0) * 80ms);
}
{/* Assign --stagger-index per element */}
<div className="stagger-item" style={{ "--stagger-index": 0 } as React.CSSProperties}>Section 1</div>
<div className="stagger-item" style={{ "--stagger-index": 1 } as React.CSSProperties}>Section 2</div>
<div className="stagger-item" style={{ "--stagger-index": 2 } as React.CSSProperties}>Section 3</div>
微妙的退出动画(Framer Motion——运动幅度小于进入动画):
<AnimatePresence>
{show && (
<motion.div
initial={{ opacity: 0, y: 8, filter: "blur(4px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -12, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.45, bounce: 0 }}
>
{children}
</motion.div>
)}
</AnimatePresence>
使用 Tailwind animate-pulse 的骨架屏加载效果:
function CardSkeleton() {
return (
<div className="rounded-lg border bg-card p-6 space-y-4">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-full animate-pulse rounded bg-muted" />
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
</div>
)
}
弹簧物理效果与缓动的选择框架
根据运动由什么驱动来选择合适的时序模型:
| 运动类型 | 使用 | 原因 |
|---|---|---|
| 用户驱动(拖动、轻甩、滑动) | 弹簧 | 可应对中断,并保留速度 |
| 系统驱动(状态变化、反馈) | 缓动 | 起止清晰,时序可预测 |
| 时间表示(进度、加载) | 线性 | 时间与进度保持 1:1 关系 |
| 高频操作(输入、快速切换) | 无 | 动画会增加干扰,让操作感觉更慢 |
| 键盘触发的操作 | 无 | 必须感觉即时,并与输入紧密关联 |
弹簧参数建议: 从 stiffness: 300, damping: 20(轻微弹跳)或 stiffness: 500, damping: 30(响应迅速,几乎没有过冲)开始。避免在刚度较高时使用 damping < 10——这会造成过度振荡。始终将拖动事件中的 velocity 传入,以保留输入能量:
onDragEnd={(e, info) => {
animate(target, { x: 0 }, {
type: "spring",
velocity: info.velocity.x,
stiffness: 500,
damping: 30,
});
}}
当动画感觉迟缓时, 应先缩短持续时间,而不是更改缓动曲线。问题几乎总是出在持续时间上。
容器边界动画
为 width 或 height 添加动画会触发布局重新计算。请改用以下模式:
双 div 模式: 外层 div 添加动画(通过 motion.div),内层 div 负责测量(通过 ref)。不要在同一个元素上同时测量和添加动画——这会形成反馈循环。
function AnimatedContainer({ children }) {
const [ref, bounds] = useMeasure()
return (
<motion.div
animate={{ height: bounds.height }}
transition={{ duration: 0.2, delay: 0.05 }}
style={{ overflow: "hidden" }}>
<div ref={ref}>{children}</div>
</motion.div>
)
}
使用 ResizeObserver 的测量 Hook(不要使用会导致布局抖动的 getBoundingClientRect):
function useMeasure() {
const [element, setElement] = useState(null)
const [bounds, setBounds] = useState({ width: 0, height: 0 })
const ref = useCallback((node) => setElement(node), [])
useEffect(() => {
if (!element) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(element)
return () => observer.disconnect()
}, [element])
return [ref, bounds]
}
- 使用回调 ref(而非
useRef),这样 DOM 节点就绪后才会附加观察器。 - 在添加动画的容器上设置
overflow: hidden,以便在过渡期间裁剪内容。 - 在首次渲染时处理
bounds === 0的情况——将高度回退为"auto"。 - **谨慎使用:**按钮、折叠面板、可展开区域。不要用于每个容器。
AnimatePresence 深入模式
mode="wait"会让感知到的时长翻倍,因为退出完成后才会开始进入动画。使用该模式时,将时间值减半。mode="sync"会造成布局冲突——进入和退出的元素会短暂共存。列表重排时改用popLayout。- **禁用退出中元素的交互。**使用
useIsPresent()并设置disabled={!isPresent},或在元素退出动画期间设置pointer-events: none——否则用户可能会点击正在消失的元素。 - 嵌套的 AnimatePresence 需要
propagate属性,以便父级退出时触发子级退出。 - 退出动画应与初始状态相呼应,营造对称感,但退出时的动效应更轻(使用固定的小幅
y: -12px,而非完整位移,并添加blur(4px)以柔化效果)。
View Transitions API
对于页面级和跨文档过渡,优先使用原生 View Transitions API,而不是 JS 动画库:
/* 1. Assign unique transition names to elements that persist across views */
.hero-image { view-transition-name: hero; }
.page-title { view-transition-name: title; }
/* 2. Style the transition pseudo-elements */
::view-transition-group(hero) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
- 每个
view-transition-name在过渡期间都必须唯一(不能有两个元素同时使用相同名称)。 - 如果过渡名称是动态分配的,完成后要清理这些名称。
- 在不支持的浏览器中会平稳回退——页面只会直接跳转,不播放动画。
7. 组件
组件理念
- **复制并自主维护,不要安装。**采用 shadcn/ui 模式:组件是粘贴到项目中并进行自定义的源代码,而不是不透明的 npm 包。代码由你掌控。
- **基于无障碍原语构建。**使用无头 UI 库(Radix、Base UI、React Aria)处理行为,再用自己的设计令牌设置样式。
- **复合组件模式。**将复杂组件拆分为可组合的部分:
Dialog.Root、Dialog.Trigger、Dialog.Content、Dialog.Title。每个部分都可单独访问。 - **使用
cn()处理类名逻辑。**在 shadcn/ui 生态系统中,clsx+tailwind-merge工具是有条件合并 Tailwind 类名的标准方式:
// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
- **通过 CSS 变量开放组件 API。**组件通过 CSS 自定义属性接受定制,而不是提供无穷无尽的属性变体:
.button { background: var(--button-bg, var(--primary)); }
组件设计令牌(shadcn/ui 模式)
完整令牌集请参阅第 3 节:实践应用。标准语义令牌包括:--background、--foreground、--card、--primary、--secondary、--muted、--accent、--destructive、--border、--input、--ring、--radius(适用时,每个令牌均有对应的 -foreground 版本)。取值使用 OKLCH 格式。
按钮模式
- **水平内边距 = 垂直内边距的 2 倍。**这能形成易于识别的按钮形状。
- 默认高度:32-36px,适用于标准密度;舒适模式下使用 40px。
- 使用
min-width,而不是固定的width,以适应不同长度的标签。 - **标签描述结果,而非过程。**使用“保存”,而不是“点击以保存更改”。
- Primary、Secondary、Ghost、Destructive——四种变体几乎适用于所有场景。
表单模式
- 使用
for/id将每个输入项与label关联。 - 使用
fieldset和legend对输入项进行分组。 - 输入框高度必须与按钮高度一致。 当按钮和输入框在行内表单中并排显示时,必须完美对齐。使用相同的高度令牌(默认 32-36px)。
- 表单元素必须继承字体:
input, button, textarea, select { font: inherit; } - 在字段附近内联显示错误, 不要通过远处的提示弹窗显示。使用
aria-describedby将错误消息与输入项关联。 - 预防错误,而不是报告错误。 使用约束、合理的默认值和实时验证。
- 占位符不是标签。 聚焦时占位符会消失。使用浮动标签,或将持久标签放在字段上方。
- 为常见字段(姓名、电子邮件、地址)应用
autocomplete属性。 - 调整 Chrome 的自动填充样式。 Chrome 会强制设置蓝色背景,并通过用户代理级别的
:-webkit-autofill和!important覆盖自动填充输入框的文本颜色。仅使用 Tailwind 的autofill:变体无法覆盖它。使用以下全局规则:
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
-webkit-box-shadow: 0 0 0px 1000px var(--background) inset !important;
-webkit-text-fill-color: var(--foreground) !important;
border-color: var(--input) !important;
transition: background-color 5000s ease-in-out 0s;
caret-color: var(--foreground);
}
内嵌框阴影会覆盖 Chrome 的蓝色背景。-webkit-text-fill-color 会强制使用你的文本颜色(常规的 color 会被覆盖)。transition: background-color 5000s 会无限期延迟 Chrome 的背景颜色变化。针对全部四种状态(基础、悬停、聚焦、激活),否则 Chrome 会在交互时重新应用其样式。注意: 5000s 的过渡延迟意味着自动填充的背景不会在实时主题切换(浅色/深色切换)时更新——这只影响运行时切换主题的网站。
图标
- 标准尺寸: 16px(行内/紧凑)、20px(默认)、24px(醒目)。空状态或主视觉区域中的装饰性图标可以更大(32-48px)。
- 描边宽度: 1.5px-2px。与文本的视觉粗细保持一致。
- 统一的
viewBox: 同一组中的所有图标应使用相同的 viewBox(通常为0 0 24 24)。 - 与文本进行视觉对齐: 使用
display: inline-flex; align-items: center,有时可进行 -1px 到 -2px 的垂直偏移,以实现视觉居中。 - 图标与文本搭配时降低图标对比度。 相同颜色下,图标在视觉上比文本更醒目。降低图标不透明度,或使用
text-muted-foreground颜色。 - 装饰性图标使用
aria-hidden="true"。仅包含图标的按钮应提供aria-label。
表格和数据展示
- 数字和货币右对齐。 文本左对齐。这样小数点可以对齐,便于快速浏览。
- 使用
font-variant-numeric: tabular-nums, 使数字在各行中保持等宽。 - 使用语义化标记:
<table>、<thead>、<tbody>、<th scope="col">、<caption>。 - 响应式模式: 在小屏幕上,将表格包裹在可水平滚动的容器中:
.table-wrapper { overflow-x: auto; -webkit-overflow-scrolling: touch; }
- 斑马纹(交替的行背景)或水平分隔线可以提高宽表格的可读性。
- 粘性表头: 对于可长距离滚动的表格,使用
thead th { position: sticky; top: 0; }。
加载状态
- 将加载指示器延迟 400ms 显示。 如果操作完成得更快,用户就不会看到加载界面,从而避免加载闪现。
- 对于内容较多的布局,优先使用骨架屏而非加载指示器。使用细微的透明度脉动效果(
animate-pulse),不要使用刺眼的渐变扫过效果。 - 骨架屏必须与其替代内容的布局相匹配, 以防止布局偏移。
- 乐观更新: 立即显示预期结果,出错时回滚。这是提升感知性能的黄金标准。
- 对于耗时超过 2-3 秒的操作,使用进度指示器。尽可能提供确定进度(百分比);无法确定时则使用不确定进度。
模态框和叠加层
- 背景遮罩: 使用半透明叠加层(
rgba(0,0,0,0.5)或backdrop-filter: blur(4px))。 - 焦点限制: Tab 键必须在模态框内循环。打开时将焦点设为第一个可交互元素,关闭时将焦点恢复到触发元素。
- 阻止页面滚动: 在
<body>上使用overflow: hidden,或在模态框上使用overscroll-behavior: contain。注意:overscroll-behavior: contain也会阻止移动设备上的下拉刷新,请测试并权衡。 - 按 Escape 关闭: 始终支持通过键盘关闭。
- 在应用根元素上设置
isolation: isolate,可避免通过 Portal 渲染的模态框出现 z-index 冲突。
空状态
- 将空状态视为设计机会,而不是事后补充。
- 清楚地指引用户接下来该做什么。
- 使用插图或细微动画,让空状态显得经过精心设计。
错误页面
- 将 404 和错误页面视为展示设计功力的机会。
- 保持网站的个性和导航方式。
- 提供有帮助的后续操作(搜索、热门页面、首页链接)。
实践:shadcn/ui 组件模式
带验证的表单(React Hook Form + Zod + shadcn):
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
const schema = z.object({
email: z.string().email("Enter a valid email"),
name: z.string().min(2, "Name must be at least 2 characters"),
})
export function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(schema),
})
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" {...register("name")} />
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" autoComplete="email" {...register("email")} />
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<Button type="submit" className="w-full">Create account</Button>
</form>
)
}
空状态组件:
import { Inbox } from "lucide-react"
import { Button } from "@/components/ui/button"
export function EmptyState({ title, description, action, onAction }) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<Inbox className="h-10 w-10 text-muted-foreground/50" />
<h3 className="mt-4 font-semibold">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground max-w-sm">{description}</p>
{action && (
<Button onClick={onAction} className="mt-4" size="sm">{action}</Button>
)}
</div>
)
}
使用 shadcn 的数据表格(关键模式):
import {
Table, TableBody, TableCell, TableHead,
TableHeader, TableRow,
} from "@/components/ui/table"
export function InvoiceTable({ invoices }) {
return (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map(inv => (
<TableRow key={inv.id}>
<TableCell className="font-medium">{inv.id}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full bg-emerald-50 px-2 py-0.5
text-xs font-medium text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
{inv.status}
</span>
</TableCell>
<TableCell className="text-right tabular-nums">{inv.amount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
延迟显示加载旋转图标(避免闪烁):
"use client"
import { useEffect, useState } from "react"
import { Loader2 } from "lucide-react"
export function DelayedSpinner({ delay = 400 }) {
const [show, setShow] = useState(false)
useEffect(() => {
const timer = setTimeout(() => setShow(true), delay)
return () => clearTimeout(timer)
}, [delay])
if (!show) return null
return <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
}
8. 响应式设计
断点策略
根据内容需求定义断点,而非设备名称。尽可能减少断点数量。
| 标记 | 宽度 | 典型用途 |
|---|---|---|
| sm | 640px (40rem) | 横屏手机 |
| md | 768px (48rem) | 竖屏平板 |
| lg | 1024px (64rem) | 横屏平板 / 小型笔记本电脑 |
| xl | 1280px (80rem) | 桌面设备 |
| 2xl | 1536px (96rem) | 大型桌面设备 |
响应式规则
- 采用移动优先的设计。 从最小的布局开始,再逐步扩展。
- 断点值使用
rem,让布局能够响应用户的字体大小偏好。 - 使用容器查询实现组件级响应。 组件应根据其容器而非视口进行调整:
@container (min-inline-size: 400px) {
.card { flex-direction: row; }
}
- 优先使用固有尺寸,而非断点。 使用
auto-fill/auto-fit网格、flex-wrap和clamp(),创建无需媒体查询也能自适应的布局。 - 绝不要仅因屏幕较小就隐藏内容。 屏幕较小不代表用户需要更少的信息。
- 优化行长。 当文本每行超过 75 个字符时添加断点。
- 在 200% 缩放下测试文本可读性(WCAG 要求)。
- 检查交互模式。 使用
@media (hover: hover)和@media (pointer: coarse),针对触屏与鼠标进行适配:
@media (pointer: coarse) {
.button { min-height: 48px; }
}
实践:Tailwind 响应式模式
响应式卡片布局(移动端堆叠 -> 桌面端网格):
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{items.map(item => <Card key={item.id} {...item} />)}
</div>
自适应导航(桌面端为侧边栏,移动端为抽屉):
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
import { Menu } from "lucide-react"
export function AppShell({ nav, children }) {
return (
<div className="flex min-h-screen">
{/* Desktop sidebar */}
<aside className="hidden lg:flex w-64 flex-col border-r p-4">{nav}</aside>
{/* Mobile sheet */}
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="lg:hidden fixed top-4 left-4 z-40">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-64 p-4">{nav}</SheetContent>
</Sheet>
<main className="flex-1 p-6 lg:p-8">{children}</main>
</div>
)
}
使用 Tailwind 的容器查询(组件根据父级宽度自适应):
<div className="@container">
<div className="flex flex-col @md:flex-row @md:items-center gap-4">
<img className="w-full @md:w-48 rounded-lg" src={src} alt={alt} />
<div className="space-y-2">
<h3 className="font-semibold">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
</div>
</div>
9. 无障碍访问
对比度(WCAG 2.1)
| 元素 | AA 最低要求 | AAA 最低要求 |
|---|---|---|
| 普通文本 | 4.5:1 | 7:1 |
| 大号文本(18px+ 粗体、24px+) | 3:1 | 4.5:1 |
| UI 组件和图标 | 3:1 | -- |
| 输入框边框 | 3:1 | -- |
键盘导航
- 所有可交互元素都必须支持键盘访问。
- 为每个可交互元素提供清晰可见的焦点样式。使用
:focus-visible(而非:focus),仅在键盘用户操作时显示轮廓:
:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
- Tab 顺序必须与视觉顺序一致。 绝不要使用 CSS Grid/Flexbox 的
order对内容重新排序,以免破坏键盘导航。 - 实现跳过链接,以便跳过导航。
- 从 Tab 顺序中移除不可见的可聚焦元素。
- 支持按
Escape关闭叠加层,按Enter/Space激活按钮。
语义化 HTML
- 使用地标元素:
<header>、<nav>、<main>、<footer>、<aside>。 - 使用正确的标题层级:每个页面使用一个
<h1>,不要跳过层级。 - 操作使用
<button>,导航使用<a>。 - 列表使用
<ul>/<ol>,表格数据使用<table>。 - 在
<html>上添加lang属性。 - 为每个页面提供唯一且描述性明确的
<title>。
图像和媒体
- 每个
<img>都需要alt属性。装饰性图像使用alt=""。 - 为图像添加
width和height,防止布局偏移。 - 禁用音频和视频的自动播放。
- 为视频提供字幕,为音频提供文字稿。
- 确保媒体控件可通过键盘操作。
表单
- 错误、警告或成功状态不能仅依赖颜色。请配合文本、图标或图案。
- 使用
aria-describedby将错误消息与输入项关联。 - 在表单上方以列表形式显示错误,方便屏幕阅读器用户查看。
- 让错误消息具有可操作性:说明问题并建议如何修复。
- 禁用的提交按钮必须说明原因。 显示缺少的内容,例如:“请填写所有必填字段”;或者保持按钮启用,并在提交时进行验证。
- Toast 不能是传达关键信息的唯一方式。 Toast 会消失。持久错误、破坏性操作的确认以及重要状态变更都需要通过行内 UI 展示。
- 加载状态需要在加载区域添加
aria-busy="true",并显示可见的状态文本。 - 通过
aria-describedby将辅助文本与输入项关联,不要只依赖视觉上的邻近关系。
动效
- 遵循
prefers-reduced-motion。减少或移除非必要动画。 - 避免每秒闪烁超过 3 次的内容(有引发癫痫的风险)。
- 允许用户暂停任何自动播放的内容。
触控目标与点击区域
- 触控目标最小尺寸:44×44px(WCAG);48×48px 为宜(Material Design)。
- 交互元素之间至少间隔 8px,避免误触。
- 使用绝对定位的伪元素,将点击区域扩展到视觉尺寸之外——这不会影响布局:
/* Small checkbox (20×20) with expanded 44×44 hit area */
.checkbox {
position: relative;
width: 20px;
height: 20px;
}
.checkbox::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 44px;
height: 44px;
}
Tailwind 等效写法:
<button class="relative size-5 after:absolute after:top-1/2 after:left-1/2 after:size-11 after:-translate-1/2">
<CheckIcon />
</button>
- **碰撞规则:**如果扩展后的点击区域与其他交互元素重叠,则缩小该区域,但在不发生碰撞的前提下尽可能保持最大尺寸。两个交互元素的点击区域绝不能重叠。
- **消除交互死区。**常见问题:表格单元格中的复选框周围有内边距,但只有很小的复选框区域可点击。将点击区域扩展至整个单元格,让用户点击内边距中的任意位置都能生效。
- **弥合导航中的视觉间隙。**使用
gap-y-px的侧边栏导航项看起来彼此分开,但交互体验应保持连续——扩展点击区域以覆盖间隙,避免项目之间出现死区。 - 使用
hit-area工具类(bazza.dev/r/hit-area)实现原生 Tailwind 方案。通过npx shadcn@latest add https://bazza.dev/r/hit-area安装。支持统一扩展(hit-area-4)、方向扩展(hit-area-l-8 hit-area-r-4)、轴向扩展(hit-area-x-4 hit-area-y-6)和自定义扩展(hit-area-[21px])。开发期间可使用hit-area-debug进行可视化调试。
10. 性能
Core Web Vitals 阈值
| 指标 | 良好 | 说明 |
|---|---|---|
| LCP | < 2.5s | 最大内容绘制(加载) |
| INP | < 200ms | 下次绘制交互时间(响应性) |
| CLS | < 0.1 | 累积布局偏移(视觉稳定性) |
性能规则
- 为所有图像设置
width和height,以防止布局偏移。 - 对首屏以下的图像使用
loading="lazy"。 - 为所有媒体元素应用
max-width: 100%和display: block。 - 对字体进行子集化,仅包含所需字符。减少载荷大小。
- 使用
font-display: swap,避免字体加载期间文本不可见。 - 优先使用 CSS 动画,而非 JavaScript。 CSS 过渡在主线程之外运行。
- 仅对
transform、opacity和clip-path设置动画,以确保 60fps 的性能。 - 谨慎使用
will-change: transform,以提示 GPU 加速。 - 对屏幕外内容使用
content-visibility: auto,跳过渲染。 - 使用
scrollbar-gutter: stable,防止滚动条出现时发生布局偏移。 - 避免使用
transition: all。明确指定要设置动画的属性。 - 绝不要交错执行 DOM 读取和写入。 在同一帧中先读取
getBoundingClientRect(),再写入style.left会导致布局抖动。应先批量完成所有读取,再批量完成所有写入。 - 对类似布局的动画使用 FLIP 技术(First、Last、Invert、Play):测量起始位置,应用最终状态,计算差值,并对反向变换设置动画。
- 模糊动画应保持较小(<=8px)且时间较短(仅用于一次性效果)。 绝不要对大面积区域或连续模糊设置动画。
- 仅在动画进行期间应用
will-change。 在mouseenter时添加,在transitionend时移除。绝不要永久保留。 - 在 Tailwind 中使用
h-dvh代替h-screen。dvh会考虑移动浏览器界面(地址栏、工具栏)的高度。h-screen会导致移动设备上的内容被浏览器界面遮挡。
图像优化
- 提供现代格式:WebP、AVIF,并提供回退格式。
- 使用响应式图像:
srcset和sizes属性。 - 设置明确的尺寸以预留空间:
aspect-ratio: 16/9。 - 使用
object-fit: cover处理意外的宽高比。
实践:Next.js 性能优化
Next.js Image 组件(处理 srcset、WebP/AVIF 和延迟加载):
import Image from "next/image"
// Replaces <img> — automatically optimized, lazy-loaded, prevents CLS
<Image
src="/hero.jpg"
alt="Product screenshot"
width={1200}
height={630}
className="rounded-lg"
priority // Add for above-fold hero images only
/>
// For fill mode (unknown dimensions):
<div className="relative aspect-video">
<Image src={url} alt={alt} fill className="object-cover rounded-lg" />
</div>
Next.js 中的字体优化(自动子集化 + swap):
// next/font handles subsetting, font-display: swap, and self-hosting automatically
import { Inter } from "next/font/google"
const inter = Inter({ subsets: ["latin"] })
// Generates: font-display: swap, optimal subsetting, no layout shift
重型组件的动态导入:
import dynamic from "next/dynamic"
// Chart library only loads when needed, with skeleton fallback
const Chart = dynamic(() => import("@/components/chart"), {
loading: () => <div className="h-64 animate-pulse rounded-lg bg-muted" />,
ssr: false,
})
乐观更新模式(React):
"use client"
import { useOptimistic } from "react"
function TodoList({ todos, addTodo }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(state, newTodo) => [...state, { ...newTodo, pending: true }]
)
async function handleAdd(formData) {
const todo = { text: formData.get("text"), id: crypto.randomUUID() }
addOptimistic(todo) // Show immediately
await addTodo(todo) // Server action — rolls back on error
}
return (
<ul className="space-y-1">
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.pending ? "opacity-50" : ""}>
{todo.text}
</li>
))}
</ul>
)
}
11. UX 心理学
关键定律
菲茨定律。 到达目标所需的时间取决于距离和大小。让重要操作更大,并靠近光标。缩短与常用目标之间的距离。在桌面界面中,将目标放在边缘或角落,可使其沿一个轴拥有“无限”尺寸。
希克定律。 决策时间会随着选项的数量和复杂程度增加。尽量减少每一步的选项。将复杂任务拆分为更小的决策。采用渐进式披露。推荐默认选项。
米勒定律。 工作记忆大约能容纳 7(±2)项内容。将信息分成易于理解的小组。不要以此为借口将 UI 限制为 7 项——应将其用于指导分组策略。
雅各布定律。 用户大部分时间都在使用其他网站。他们期望你的网站以相同的方式运作。遵循平台惯例。没有充分理由时,不要创新交互模式。
多尔蒂阈值。 当系统响应时间低于 400ms 时,生产力会大幅提升。使用骨架屏、乐观更新和加载指示器,保持用户感知到的速度。
审美-易用性效应。 用户会认为美观的设计更易于使用。投入精力打磨视觉效果——这会直接影响用户感知到的质量,以及他们对问题的耐心。
冯·雷斯托夫效应(孤立效应)。 群体中最突出的项目最容易被记住。让最重要的元素(CTA、关键指标)在视觉上与众不同。
峰终定律。 人们会根据体验中的高峰时刻和结束时的感受来评价体验,而不是根据平均感受。应投入更多精力打造高潮时刻和最终印象。
蔡格尼克效应。 人们对未完成任务的记忆比对已完成任务的记忆更深刻。进度条和步骤指示器利用这一效应推动用户完成任务。
特斯勒定律(复杂性守恒定律)。 每个系统都存在不可消除的复杂性。问题在于由谁承担——用户还是系统。应在实现中吸收复杂性,让界面保持简单。
波斯特尔定律。 接受输入时保持宽容,输出时保持严格。接受多种输入格式。输出整洁、一致的格式。
其他定律
选项过载。 选项过多会让用户不知所措,并降低满意度。提供筛选功能、推荐默认选项,并将复杂决策拆分为多个步骤。
目标梯度效应。 人们越接近目标,行动速度越快。显示进度条、预填部分进度,并让多步骤流程始终可见。
序列位置效应。 用户对列表中首尾项目的记忆最好。将关键导航项目放在开头和末尾,把不太重要的项目放在中间。
帕累托原则(80/20 法则)。 80% 的使用量来自 20% 的功能。找出并优化关键的 20%。
选择性注意(横幅盲视)。 用户会忽略任何看起来像广告的内容。不要把真实内容设计得像广告。
格式塔原则
- 接近性。 彼此靠近的对象会被视为一组。
- 相似性。 外观相似的对象会被视为相关。
- 连续性。 排列成直线或曲线的元素会被视为相关。
- 闭合性。 人脑会补全不完整的形状。
- 共同区域。 位于同一边界内的元素会被视为一组。
- 图形-背景。 人脑会将前景与背景区分开来。
12. 设计令牌
令牌架构
将令牌分层组织:
- 原始令牌。 原始值:
--blue-500: #3b82f6。这些构成调色板。 - 语义令牌。 按用途映射:
--color-primary: var(--blue-500)。这些令牌表达含义。 - 组件令牌。 限定作用域:
--button-bg: var(--color-primary)。这些令牌允许在组件级别进行覆盖。
标准令牌类别
颜色: 背景、前景、主色、次要色、弱化色、强调色、破坏性颜色、边框、输入框、焦点环,以及语义颜色(成功、警告、信息、错误)。
排版: 字体系列(无衬线、标题、等宽)、字体大小(xs 至 4xl)、字重(常规、中等、半粗体、粗体)、行高、字间距。
间距: 基于 4px 网格(见第 1 节)。
边框: 圆角(sm、md、lg、xl、full)、宽度(1px、2px)、颜色(取自颜色令牌)。
阴影: sm、md、lg、xl、2xl(见第 4 节)。
动效: 时长(快速:150ms,常规:250ms,缓慢:500ms)、缓动函数。
Z-index: 分层刻度:base(0)、dropdown(1000)、sticky(1100)、modal(1200)、popover(1300)、toast(1400)。
CSS 自定义属性模式
:root {
/* Primitives */
--gray-50: #fafafa;
--gray-900: #171717;
--blue-500: #3b82f6;
/* Semantic */
--background: var(--gray-50);
--foreground: var(--gray-900);
--primary: var(--blue-500);
/* Component */
--button-bg: var(--primary);
--button-text: white;
--card-bg: var(--background);
--card-border: var(--gray-200);
}
.dark {
--background: var(--gray-900);
--foreground: var(--gray-50);
--primary: var(--blue-400);
}
响应式令牌
在断点处更改令牌值,无需改动组件:
:root {
--space-section: 2rem;
--text-hero: 2rem;
}
@media (min-width: 768px) {
:root {
--space-section: 4rem;
--text-hero: 3.5rem;
}
}
13. 打磨与匠心
优秀与卓越的区别
业余设计与专业设计的区别,在于成百上千个微小而用心的决定不断累积。没有任何一个细节能彻底改变产品,但这些细节汇聚起来,就会带来用户所说的“就是感觉很对”的品质感。
值得践行的设计原则
- 统一。 每个部分都应构成连贯体系的一环。不要有孤立的功能。
- 普适。 产品应欢迎并服务全球社群,兼顾无障碍体验。
- 鲜明。 在设计和功能上都要聚焦。表达要大胆、清晰。
- 善于沟通。 动效让产品充满活力。以易于理解的方式传递信息。
- 细致入微。 关注每个界面——错误页面、空状态、页脚。
- 设计越少越好。 好的设计应尽可能简洁。少即是多。
- 让艰苦工作隐于幕后。 下足功夫,让用户轻松使用。
- 别让我思考。 界面应该一目了然。如果需要解释,就需要重新设计。
业余与专业的做法
| 业余做法 | 专业做法 |
|---|---|
| 动画时长随意 | 时长经过考量:在 100-300ms 范围内,并根据触发区域大小调整 |
| 状态变化时布局发生偏移 | 使用隐藏的伪元素预留粗体文本的宽度 |
| 图片带有硬边框 | 使用半透明颜色的内嵌 box-shadow |
| 点击目标与视觉尺寸完全相同 | 使用不可见的 ::before 扩大可点击区域 |
| 忽略错误页面、空状态和页脚 | 将每个界面都视为打磨体验的机会 |
| 触屏设备上也会触发悬停效果 | 使用 @media (hover: hover) 隔离悬停状态 |
| 切换主题时触发 CSS 过渡 | 禁用过渡、应用主题,并在下一帧重新启用 |
| 所有交互都使用相同动画 | 减少频繁操作的动效;只在有意义的状态变化时使用动效 |
下拉菜单通过 click 打开 | 使用 mousedown 触发,带来更即时的感受 |
| 等待服务端更新界面 | 采用乐观更新,出错时回滚 |
点睛之笔
- 强调色边框。 在卡片一侧添加 3-5px 的彩色边框,无需显得厚重,也能增添视觉趣味。
- 背景装饰。 细腻的渐变、圆点或网格图案能避免空白区域显得单调。
- 自定义选中颜色。 为保持品牌一致性,可设置
::selection的样式(但要确保对比度充足)。 - 支持深色模式的 SVG 网站图标。 在 SVG 内使用
prefers-color-scheme媒体查询,让网站图标自适应主题。 - 自定义滚动条(适当使用,并确保易用性)。
实践:React + Tailwind 中的打磨模式
强调色边框卡片(左侧彩色条):
<div className="rounded-lg border-l-4 border-l-blue-500 bg-card p-4 shadow-sm">
<h3 className="font-semibold">New deployment</h3>
<p className="text-sm text-muted-foreground">Production build completed in 42s.</p>
</div>
带自然内嵌边框的图片(无生硬边缘):
<div className="relative overflow-hidden rounded-lg">
<img src={src} alt={alt} className="w-full" />
<div className="absolute inset-0 rounded-lg ring-1 ring-inset ring-black/5" />
</div>
隔离触屏设备上的悬停效果(Tailwind):
<button className="rounded-lg bg-secondary px-4 py-2
transition-colors
hover:[@media(hover:hover)]:bg-secondary/80
active:scale-[0.97]">
Action
</button>
在 globals.css 中自定义选中颜色:
::selection { background-color: oklch(0.8 0.1 250); color: oklch(0.2 0 0); }
供键盘用户使用的跳转链接:
<a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4
focus:z-50 focus:rounded-lg focus:bg-background focus:px-4 focus:py-2 focus:text-sm
focus:shadow-lg focus:ring-2 focus:ring-ring">
Skip to content
</a>
<main id="main">{children}</main>
支持深色模式的响应式 SVG 网站图标(app/icon.svg):
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
circle { fill: #111 }
@media (prefers-color-scheme: dark) { circle { fill: #eee } }
</style>
<circle cx="16" cy="16" r="14" />
</svg>
14. 微文案与 UX 写作
核心原则
- 简洁至上。 按钮标签应为 1-3 个词。“保存”,而不是“点击以保存更改”。“复制”,而不是“将此文本复制到剪贴板”。
- 标签描述结果,而非过程。“加入”,而不是“提交注册信息”。“删除”,而不是“从数据库中永久移除此项”。
- **使用以行动为导向的语言。**行动号召使用动词:“加入候补名单”“开始使用”“继续”。绝不要使用名词短语。
- **文案的分量应与操作的重要性相匹配。**破坏性操作需要更严肃的文案。删除确认的措辞应比切换开关的标签更郑重。
- **渐进式披露也适用于文案。**分步呈现信息,让用户易于消化,不要一次性全部呈现。
按钮和行动号召标签
| 模式 | 示例 |
|---|---|
| 单词动词 | 保存、复制、删除、加入、分享 |
| 短语 | 加入候补名单、开始使用、了解更多 |
| 状态变化标签 | 复制 -> 已复制,保存 -> 已保存,发送 -> 已发送 |
| 方向导航 | 上一页 / 下一页,返回 / 继续 |
加载和进度状态
- 三阶段进度:“思考中...” -> “生成中...” -> “完成”
- 描述系统正在做什么,而不是用户应该做什么。
- 进行中的状态使用省略号(保存中...、加载中...),完成状态不使用省略号(完成、已保存)。
- **逐步提高具体程度:**从抽象的“处理中...”开始,以具体的“完成”结束。
错误消息
- **预防错误,而不是报告错误。**实时验证胜过提交后的错误列表。
- 说明问题并建议解决方法。“密码必须包含数字”,而不是“密码无效”。
- **将文本与视觉反馈结合起来。**同时使用抖动动画、红色边框和内联消息。
- 使用通俗语言。“此邮箱已注册”,而不是“错误 409:冲突”。
成功和确认
- **简短,并自动消失。**提示框显示“已复制”,并在 1.5-2 秒后自动消失。
- 完成时语气亲切。“一切就绪”比“操作已成功完成”更好。
- **建议下一步操作。**确认后提供相关的后续操作。
语气指南
- **对话式,但准确。**表达轻松,但技术上正确。
- 语气肯定,不要含糊其辞。“这会删除你的账户”,而不是“这可能会删除你的账户”。
- 输入阶段提供指导,完成时表达祝贺。
- **绝不居高临下。**常规操作避免使用感叹号。只在真正的重要节点使用。
15. 避免 AI 味
AI 代码生成器常用的模式,显得通用、低质或一眼就像机器生成。应避免这些模式。
视觉反模式
- 不要使用紫色或多色渐变,除非品牌明确采用这类设计。这是最容易辨认的 AI 生成美学特征。
- **不要将发光效果作为主要操作提示。**发光只是装饰,不具备功能性。即使没有发光效果,按钮也应一眼可辨。
- **不要使用通用字体栈。**不要凡事都默认使用 Inter、Roboto 或 system-ui。选择符合项目个性的字体。如果没有品牌方向,可以使用 Inter——但要承认它只是起点,而非经过深思熟虑的设计选择。
- **每个视图只用一种强调色。**多种相互竞争的强调色会造成视觉噪音。为主要操作选择一种颜色,其余元素保持中性。
- **除非有明确要求,否则不要使用渐变。**默认使用纯色。渐变应是有意识的设计选择,而不是备用方案。
- **除非能为内容提供帮助,否则不要使用装饰性 SVG 色块、网格渐变或抽象形状。**这些只是填充内容,会让人觉得“没有设计方向”。
结构反模式
- 不要使用完全相同的指标卡片。 在网格中重复 4 次“左侧图标、大号数字、小号标签”的模式,会显得像套用模板。应根据内容类型改变卡片的内部结构。
- 不要使用千篇一律的侧边栏 + 卡片网格。 AI 默认采用这种布局,因此每个仪表盘看起来都一样。应从用户的首要任务出发,围绕它构建布局。
- 不要把所有内容都居中。 文字居中、卡片居中、主视觉居中——这是 AI 的默认做法。默认左对齐。只有在内容简短且刻意追求对称时(例如主视觉标题、空状态)才居中。
- **不要把落地页做成垂直堆叠的分区,**再交替排列左右图文块。要打破这种节奏。
代码反模式
- 不要使用
h-screen。 使用h-dvh(动态视口高度)。h-screen会让内容被移动浏览器的界面遮挡。 - 不要使用
z-[9999]。 使用设计令牌层级:下拉菜单(1000)、粘性元素(1100)、模态框(1200)、弹出层(1300)、提示消息(1400)。 - 不要使用
transition-all。 明确指定要过渡的属性。 - 不要用内联样式做设计决策。 使用 Tailwind 工具类或 CSS 变量。布局数值(计算得出的宽度、由 JS 设置的位置)可以使用内联样式。
- 不要用
useEffect处理渲染逻辑。 派生状态应在渲染期间计算,而不是放在 effect 中。
“人类会把它正式发布吗?”测试
在认为 UI 工作完成之前,请检查以下几点:
- 标志性元素: 你能指出一个体现此设计专属于该产品的元素吗?如果不能,它就只是个模板。
- 眯眼测试: 模糊视线。层级是否清晰?主要操作是否一目了然?
- 替换测试: 换掉 Logo 后,它可以属于任何其他产品吗?如果可以,设计就缺乏辨识度。
- 以全新视角审视: 明天再看,它仍然感觉合适吗?还是有什么地方让你耿耿于怀?
16. 进阶技巧
让精致的生产级界面有别于原型的技巧。
自然阴影
Tailwind 的默认阴影使用灰色。要营造更丰富的层次感,可以用背景色相为阴影着色:
:root {
--shadow-color: 220deg 60% 50%; /* match your background hue */
}
.card-natural-shadow {
box-shadow:
0 1px 1px hsl(var(--shadow-color) / 0.075),
0 2px 2px hsl(var(--shadow-color) / 0.075),
0 4px 4px hsl(var(--shadow-color) / 0.075),
0 8px 8px hsl(var(--shadow-color) / 0.075);
}
按分区或主题调整 --shadow-color,让阴影与其环境协调一致。在深色模式下,阴影会失去视觉逻辑,因此应改用细微的 border 和更亮的表面色(bg-card)。
生成调色板
根据单一基础颜色生成颜色状态(HSB/HSL):
- 较深变体(悬停、激活): 降低亮度,提高饱和度。也可以将色相朝蓝色或紫色偏移。
- 较浅变体(着色背景、徽章): 提高亮度,降低饱和度。也可以将色相朝黄色或青色偏移。
- 禁用状态: 大幅降低饱和度,提高亮度。
{/* Primary button with derived hover/active states */}
<button className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800
text-white transition-colors">
Confirm
</button>
{/* Tinted background from same color family */}
<div className="bg-blue-50 text-blue-700 dark:bg-blue-950 dark:text-blue-300 rounded-lg p-4">
Info message using the same blue family at lower intensity.
</div>
避免渐变发灰
标准 CSS 渐变在 sRGB 中进行插值,导致高饱和度颜色之间的中间色发灰。可通过指定 OKLCH 插值来解决:
/* Muddy (default sRGB interpolation) */
background: linear-gradient(to right, #ff0 , #00f);
/* Clean (perceptually uniform) */
background: linear-gradient(to right in oklch, oklch(0.9 0.4 110), oklch(0.5 0.3 264));
在 Tailwind 中,使用任意值:
<div className="bg-[linear-gradient(to_right_in_oklch,oklch(0.8_0.15_200),oklch(0.7_0.2_280))]" />
圆角平滑处理(超椭圆)
CSS border-radius 会创建圆弧,在与直边相接处形成明显的切点。iOS 使用超椭圆(圆润方形),曲线会在圆弧处之前就开始弯曲,形状更平滑、更自然。
纯 CSS 方案(足以应付大多数情况): 使用更大的圆角。将 border-radius 设为较短边尺寸的约 22-28%,即可近似圆润方形的效果。在常见尺寸的卡片上,Tailwind 的 rounded-2xl(16px)到 rounded-3xl(24px)看起来都很接近。
**若要达到像素级的 iOS 还原效果,**请使用 SVG 超椭圆剪切路径或 figma-squircle 包:
import { getSvgPath } from "figma-squircle"
function Squircle({ width, height, children }) {
const path = getSvgPath({
width, height,
cornerRadius: 20,
cornerSmoothing: 0.6, // 0 = CSS radius, 1 = full squircle
})
return (
<div style={{ clipPath: `path('${path}')`, width, height }}>
{children}
</div>
)
}
适用场景: App 图标、主视觉卡片和高端产品图片。对于标准 UI 卡片,使用 rounded-xl 或 rounded-2xl 即可。
毛玻璃 / 磨砂玻璃
<div className="rounded-xl border border-white/20 bg-white/10 p-6
backdrop-blur-xl shadow-xl">
<h3 className="font-semibold text-white">Glass Card</h3>
</div>
规则:
- 使用
backdrop-blur-md(12px)实现轻微效果,使用backdrop-blur-xl(24px)实现强烈磨砂效果。 - 背景必须半透明(
bg-white/10到bg-white/20)。 - 添加
border border-white/20,呈现玻璃边缘的反光。 - 在繁杂或色彩丰富、能显现模糊效果的背景上效果最佳。
- 在移动设备上测试性能——
backdrop-filter会大量占用 GPU 资源。 - 优雅降级:不支持
backdrop-filter时,会显示半透明卡片(仍可正常使用)。
噪点与纹理
平坦的表面可能显得呆板。叠加轻微噪点可以增添自然的温暖感:
.textured {
position: relative;
}
.textured::after {
content: '';
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
mix-blend-mode: overlay;
border-radius: inherit;
}
不透明度保持在 0.02-0.05 之间。更高会让表面看起来脏兮兮的。
渐变遮罩(滚动渐隐)
在滚动容器边界处淡出内容:
<div className="relative max-h-64 overflow-y-auto
[mask-image:linear-gradient(to_bottom,transparent,black_16px,black_calc(100%-16px),transparent)]">
{/* scrollable content */}
</div>
内嵌环与边框
对于图像容器和头像框,内嵌环比边框更自然,因为它不会增加元素尺寸,并且能与内容融合:
{/* Hard border (adds 2px to dimensions, feels rigid) */}
<img className="rounded-full border-2 border-gray-200" />
{/* Inset ring (no dimension change, blends naturally) */}
<div className="relative rounded-full overflow-hidden">
<img className="rounded-full" src={src} alt={alt} />
<div className="absolute inset-0 rounded-full ring-1 ring-inset ring-black/10" />
</div>
{/* Shorthand for non-image elements */}
<div className="rounded-lg shadow-[inset_0_0_0_1px_rgba(0,0,0,0.06)]">
类型化 CSS 自定义属性
使用 @property 为自定义属性启用动画,并捕获无效值:
@property --gradient-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.rotating-border {
--gradient-angle: 0deg;
border-image: conic-gradient(from var(--gradient-angle), #e92a67, #a853ba, #2a8af6, #e92a67) 1;
animation: rotate-gradient 3s linear infinite;
}
@keyframes rotate-gradient {
to { --gradient-angle: 360deg; }
}
如果没有 @property,CSS 就无法对自定义属性进行插值——动画会在不同值之间跳变,而不是平滑过渡。
按钮阴影剖析(六层)
精致的按钮会结合六种分层技术,模拟现实世界的光照物理效果:
.button-polished {
/* Subtle gradient for dimension */
background: linear-gradient(
to bottom,
color-mix(in oklch, var(--primary), white 4%),
var(--primary)
);
box-shadow:
/* 1. Outer cut: thin dark edge */
0 0 0 0.5px rgba(0, 0, 0, 0.15),
/* 2. Inner ambient highlight: light from all sides */
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
/* 3. Inner top highlight: primary light from above */
inset 0 1px 0 rgba(255, 255, 255, 0.15),
/* 4-6. Layered depth shadows */
0 1px 2px rgba(0, 0, 0, 0.15),
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.05);
/* Text shadow for contrast */
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
}
这种技术用具有真实质感的立体效果取代扁平按钮。渐变增加层次感,内嵌阴影模拟环境光和定向光,外部阴影则营造深度感。
半透明边框
使用基于 alpha 的边框颜色,而不是硬编码的十六进制值。半透明边框会自动适应任何背景:
/* Hardcoded (breaks on different backgrounds) */
border: 1px solid #e5e5e5;
/* Adaptive (works on any background) */
border: 1px solid rgba(0, 0, 0, 0.12);
/* Dark mode equivalent */
border: 1px solid rgba(255, 255, 255, 0.12);
这样在许多情况下就不需要单独设置浅色/深色边框令牌。
CSS 伪元素模式
使用原生伪元素和选择器,降低 DOM 复杂度:
::marker用于自定义列表项目符号——取代背景图片技巧:
li::marker { color: var(--muted-foreground); font-size: 0.8em; }
::first-line用于排版样式——会随视口变化自动适配:
.article p:first-of-type::first-line {
font-variant-caps: small-caps;
font-weight: 500;
}
::backdrop用于对话框/弹出层叠加层——取代额外的叠加层 div:
dialog::backdrop { background: rgba(0, 0, 0, 0.5); backdrop-filter: blur(4px); }
::placeholder用于输入框样式(对比度应低于实际输入文本)。
视觉微调
眼睛会期待、但数学计算无法提供的小幅修正:
- 播放按钮图标需要向右偏移 1-2px 才显得居中(三角形的视觉重心位于其边界框中心的左侧)。最佳做法:直接在 SVG 内修正偏移,避免额外的 CSS 调整。
- 带文本和图标的按钮需要在图标一侧减少内边距。图标边界框内的留白会造成视觉失衡——将该侧的内边距减少 2-4px 以作补偿。
- 方形容器中的圆形图标看起来会更小。将圆形图标尺寸增大约 5%,或减小容器内边距。
- 复选框/单选按钮旁的文本在小尺寸下需要设置 -1 至 -2px 的上外边距,因为基线位于视觉中心上方。
- 药丸徽标(
rounded-full)需要比其圆角半径所暗示的略多一些水平内边距——额外增加 2-4px。 - 按钮中的全大写文本看起来会垂直偏离中心。将底部内边距再增加 1px,或使用
leading-none并通过内边距手动居中。
17. 防御式 CSS
避免常见布局错乱的模式:
/* Prevent flex items from overflowing */
.flex-child { min-width: 0; overflow-wrap: break-word; }
/* Prevent images from breaking layout */
img { max-width: 100%; display: block; object-fit: cover; }
/* Prevent scroll chaining through modals */
.modal { overscroll-behavior: contain; }
/* Always use auto overflow, not scroll */
.scrollable { overflow-y: auto; }
/* Reserve scrollbar space to prevent layout shift */
.page { scrollbar-gutter: stable; }
/* Use min-height, not height, for variable content */
.hero { min-height: 400px; } /* NOT height: 400px */
/* Use min-width, not width, for buttons */
.button { min-width: 100px; } /* NOT width: 100px */
/* Fallback backgrounds for failed images */
.card-image { background-color: var(--muted); }
/* Fallback for CSS variables */
.element { width: var(--sidebar-width, 250px); }
/* Prevent flex containers from hiding overflow */
.grid-child { min-width: 0; }
/* Use auto-fill to prevent stretching with few items */
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
/* Sticky elements in grid need align-self */
.sidebar { align-self: start; position: sticky; top: 1rem; }
/* Single-line text truncation */
.truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Multi-line text truncation (e.g., 3 lines) */
.line-clamp-3 {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
/* Safe area padding for full-bleed layouts on notched devices */
.safe-area { padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
/* Never use arbitrary z-index values. Always map to a token layer. */
/* z-base: 0, z-dropdown: 1000, z-sticky: 1100, z-modal: 1200, z-popover: 1300, z-toast: 1400 */
18. 现代 CSS 重置
适用于任何项目的合理起点:
/* 1. Use border-box for everything */
*, *::before, *::after { box-sizing: border-box; }
/* 2. Remove default margins */
* { margin: 0; }
/* 3. Enable smooth keyword animations (respects motion prefs) */
@media (prefers-reduced-motion: no-preference) {
html { interpolate-size: allow-keywords; }
}
/* 4. Accessible body defaults */
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/* 5. Responsive media */
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
/* 6. Form elements inherit fonts */
input, button, textarea, select { font: inherit; }
/* 7. Prevent text overflow */
p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }
/* 8. Better text wrapping */
p { text-wrap: pretty; }
h1, h2, h3, h4, h5, h6 { text-wrap: balance; }
/* 9. Root stacking context */
#root, #__next { isolation: isolate; }
19. 预测式预取
通过分析光标轨迹,在用户点击前加载内容,将感知延迟降低 100-200ms。
轨迹预测与悬停
标准的基于悬停的预加载(onMouseEnter)会等到用户停止移动并已经将光标停在元素上才开始。轨迹预测会在光标仍在移动时计算其前进方向,挽回基于悬停的方法浪费的约 100-200 ms。
// Hover-based (too late — fires after the user has arrived)
<Link href="/about" onMouseEnter={() => router.prefetch("/about")}>About</Link>
// Trajectory-based (fires while cursor is still moving toward the element)
const { elementRef } = useForesight({
callback: () => router.prefetch("/about"),
hitSlop: 20, // expand invisible prediction area by 20px
name: "about-link",
})
<Link ref={elementRef} href="/about">About</Link>
规则
- **根据意图预加载,而非根据视口预加载。**不要预加载所有可见链接。大多数用户不会点击大多数可见链接。使用轨迹预测或悬停来判断意图。
- **使用
hitSlop更早开始预测。**扩大元素周围的不可见区域(例如 20 px),即可更早开始加载,且不会产生视觉变化。 - **在触控设备上平稳回退。**触控设备没有光标轨迹。回退到
touchstart或基于视口的策略。 - **在键盘导航时预加载。**监控焦点变化,并在用户距离目标还差几个 Tab 停靠位置时预加载。
- **有选择地使用。**预测式预加载最适合数据密集型仪表板、API 缓慢的多页面应用和电子商务网站。对于导航即时完成的静态网站,则无需使用。
参考:ForesightJS
20. 音频反馈与声音设计
音频是 UI 工具,而非装饰。正确使用时,它可以强化操作并营造真实的交互感;使用不当则会令人烦躁。
何时使用声音
| 交互 | 使用声音? | 原因 |
|---|---|---|
| 支付成功 | 是 | 重要确认 |
| 表单提交 | 是 | 用户需要确认 |
| 错误状态 | 是 | 不容忽视 |
| 通知 | 是 | 用户可能没有看屏幕 |
| 重要按钮(例如发送) | 也许 | 仅用于里程碑操作 |
| 输入文字 | 否 | 过于频繁 |
| 悬停 | 否 | 仅作装饰,没有信息价值 |
| 滚动 | 否 | 过于频繁 |
| 导航 | 否 | 键盘导航会产生过多声音 |
无障碍规则
- **每种声音都必须有对应的视觉反馈。**声音补充视觉反馈,绝不能取代它。
- **提供关闭声音的开关。**尊重用户偏好。
- 尊重
prefers-reduced-motion——同时减少或禁用声音和动态效果。 - **声音强度应与操作重要性相匹配。**切换操作使用轻柔的点击声,购买成功使用提示音。切勿为复选框播放庆祝乐曲。
- **声音时长应与操作时长相匹配。**点击声:5-15 ms。确认声:约 200 ms。即时操作配上长音效会显得不协调。
- **提供信息,而非惩罚。**验证错误使用轻柔提醒,而非刺耳蜂鸣。
实现
// Preload sounds to avoid first-play delay
const sounds = {
success: new Audio("/sounds/success.mp3"),
error: new Audio("/sounds/error.mp3"),
click: new Audio("/sounds/click.mp3"),
}
Object.values(sounds).forEach(a => { a.volume = 0.3; a.load() })
function playSound(name: keyof typeof sounds) {
sounds[name].currentTime = 0 // reset so rapid re-triggering works
sounds[name].play()
}
- 默认音量:0.3(30%)。绝不要设为 1.0 ——会吓到用户。
- 每次播放前重置
currentTime = 0,以便快速重复触发。 - 预加载音频文件,避免首次触发时出现延迟。
使用 Web Audio API 合成声音
无需音频文件的 UI 声音——以编程方式生成点击声和音调:
// Singleton AudioContext (never create per sound)
let ctx: AudioContext | null = null
function getCtx() { return ctx ??= new AudioContext() }
// Click sound: filtered noise burst (5-15ms)
function playClick() {
const ctx = getCtx()
if (ctx.state === "suspended") ctx.resume()
const buffer = ctx.createBuffer(1, ctx.sampleRate * 0.008, ctx.sampleRate)
const data = buffer.getChannelData(0)
for (let i = 0; i < data.length; i++) {
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / 50)
}
const source = ctx.createBufferSource()
source.buffer = buffer
const filter = ctx.createBiquadFilter()
filter.type = "bandpass"
filter.frequency.value = 4000 // 3000-6000Hz for crisp clicks
filter.Q.value = 3 // 2-5 for focused but natural
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.05)
source.connect(filter).connect(gain).connect(ctx.destination)
source.start()
source.onended = () => { source.disconnect(); filter.disconnect(); gain.disconnect() }
}
// Tonal sound: oscillator with pitch sweep (confirmations, pops)
function playTone() {
const ctx = getCtx()
if (ctx.state === "suspended") ctx.resume()
const t = ctx.currentTime
const osc = ctx.createOscillator()
osc.frequency.setValueAtTime(400, t)
osc.frequency.exponentialRampToValueAtTime(600, t + 0.04) // upward sweep
const gain = ctx.createGain()
gain.gain.setValueAtTime(0.3, t)
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.08)
osc.connect(gain).connect(ctx.destination)
osc.start(t)
osc.stop(t + 0.08)
osc.onended = () => { osc.disconnect(); gain.disconnect() }
}
Web Audio 的关键规则:
- 复用单个
AudioContext——切勿为每种声音都创建一个(浏览器限制约为 6 个)。 - 播放前恢复已挂起的上下文(
ctx.state === "suspended")。浏览器会将 AudioContext 挂起,直到用户进行交互。 - 播放后断开节点,以避免内存泄漏。
- 使用指数曲线衰减(而非线性衰减)。目标值设为
0.001(而非0——指数曲线无法达到零)。 - 打击乐类声音使用噪声(点击声、轻敲声)。有音高的声音使用振荡器(轻弹声、确认音)——添加音高扫频以增强音乐感。
| 用户反馈 | 调整方法 |
|---|---|
| “太刺耳” | 降低滤波器频率,减小 Q 值 |
| “太沉闷” | 提高滤波器频率 |
| “太长” | 缩短缓冲区时长,加快衰减 |
| “突然中断” | 使用指数衰减而非线性衰减 |
| “更有机械感” | 提高 Q 值,加快衰减 |
| “更柔和” | 降低增益,使用三角波 |
快速参考:常用值
| 属性 | 推荐默认值 |
|---|---|
| 正文大小 | 16px (1rem) |
| 正文行高 | 1.5 |
| 标题行高 | 1.1 - 1.25 |
| 内容最大宽度 | 65ch |
| 间距基础单位 | 4px |
| 边框圆角 (sm) | 4px |
| 边框圆角 (md) | 8px |
| 边框圆角 (lg) | 12px |
| 边框圆角 (full) | 9999px |
| 边框圆角 (shadcn 默认值) | 0.625rem (10px) |
| 过渡效果 (fast) | 150ms ease-out |
| 过渡效果 (normal) | 250ms ease-out |
| 过渡效果 (slow) | 500ms ease-out |
| 最小触控目标 | 44px(优选 48px) |
| 最小对比度(文本) | 4.5:1 |
| 最小对比度(大号文本) | 3:1 |
| 最小对比度(UI 元素) | 3:1 |
| 最大行长 | 最佳为 60-75 个字符,最多 80 个 |
| 按钮/输入框高度 | 32-36px(标准),40px(舒适) |
| 图标尺寸 | 16px(紧凑),20px(默认),24px(醒目) |
| 按钮内边距比例 | 水平 2x:垂直 1x |
| 阴影模糊比例 | 偏移距离的 2x |
| 加载指示器延迟 | 显示前等待 400ms |
| 系统响应目标 | < 400ms(多尔蒂阈值) |
| 颜色格式 | OKLCH(优选),HSL(备选) |
| 焦点环 | 2px solid,2px 偏移 |
| Z-index 层级 | 下拉菜单:1000,粘性元素:1100,模态框:1200,弹出框:1300,提示:1400 |