mcpField
W
web-design

web-design

github.com/pascalorg设计与 UI101178 次安装7 周前更新
代码仓库

关于 web-design

web-design 是一项面向网页界面构建、评审和改进的实践参考技能。内容按结构、视觉设计、交互、无障碍和细节打磨分层组织,汇集多种设计参考的建议。

主要功能

  1. 01分层安排界面实现优先级
  2. 02响应式布局与间距建议
  3. 03字体排版与色彩原则
  4. 04无障碍和交互状态检查
  5. 05动效、性能与 CSS 建议

适用场景

  1. 01规划网页布局
  2. 02检查响应式表现
  3. 03审查无障碍与组件状态
  4. 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 层:结构(先把这部分做好)
  1. 语义化 HTML。 使用正确的元素(<button>、<nav>、<main>,标题按顺序排列)。其他一切都以此为基础。
  2. 布局。 网格/弹性盒结构、间距比例、内容宽度限制(max-w-prose、12 列网格)。页面在所有视口尺寸下都能保持完整吗?
  3. 响应式行为。 移动端优先、内在尺寸(auto-fill 网格、clamp()、flex-wrap)。除非有充分理由,否则不要在小屏幕上隐藏内容。
  4. 排版基础。 字号比例、行高(正文 1.5,标题 1.1-1.25)、行长(max-width: 65ch)、字号使用 rem 单位。
第 2 层:视觉系统(设计的骨干)
  1. 颜色和对比度。 应用配色方案,设置语义化令牌,满足 WCAG AA 对比度要求(文本 4.5:1,UI 3:1)。需要时支持深色模式。
  2. 视觉层级。 四个文本层级清晰有效(前景色、弱化色、弱化色/70、弱化色/50)。主要操作一目了然。通过眯眼测试。
  3. 组件状态。 为每个交互元素考虑悬停、可见焦点、激活、禁用、加载、错误和空状态。
  4. 间距和比例。 外边距 >= 内边距。避免含糊不清的间隙。按钮内边距比例(水平 2x : 垂直 1x)。输入框高度与按钮高度一致。
第 3 层:交互和动效(让界面鲜活起来)
  1. 键盘和无障碍访问。 焦点样式、Tab 顺序、跳过链接、aria 属性、触控目标(44px+)。仅使用键盘进行测试。
  2. 过渡效果。 悬停/激活反馈(150ms ease-out)、状态过渡(200-300ms),根据方向使用恰当的缓动(进入用 ease-out,退出用 ease-in)。
  3. 动画。 入场动画采用错峰效果,退出动画轻微自然,过渡可被中断,遵循 prefers-reduced-motion 设置。适当时使用弹簧物理效果。
第 4 层:润色(让设计更精致的最后 10%)
  1. 阴影和层次。 使用多层阴影,在合适的情况下用阴影代替边框,并保持光源方向一致。深色模式:调整表面亮度,而非使用阴影。
  2. 视觉微调。 图标侧的按钮内边距、同心圆角、播放按钮偏移、数据使用等宽数字、为图片添加轮廓。
  3. 微交互。 根据上下文设置图标动画、错峰入场时添加模糊效果、复制到剪贴板的反馈、乐观更新。
  4. 防御式 CSS。 为弹性子项设置 min-width: 0,使用 overflow-wrap: break-word、scrollbar-gutter: stable、文本截断和安全区域内边距。
  5. 最终检查。 眯眼测试、灰度测试、替换测试、“人类会交付这个设计吗?”测试。避免 AI 垃圾设计(无端的渐变、千篇一律的指标卡片、所有内容都居中)。

经验法则: 如果布局在 768px 时就会崩坏,就别再纠结阴影不透明度了,回到第 1 层。

目录

  1. 布局与间距
  2. 排版
  3. 颜色
  4. 阴影与层次
  5. 视觉层级
  6. 动画与动效
  7. 组件
  8. 响应式设计
  9. 无障碍
  10. 性能
  11. 用户体验心理学
  12. 设计令牌
  13. 打磨与工艺
  14. 微文案与 UX 写作
  15. 避免 AI 味
  16. 进阶工艺
  17. 防御式 CSS
  18. 现代 CSS 重置
  19. 预测性预取
  20. 音频反馈与声音设计

1. 布局与间距

间距比例

使用以基础单位为根基的一致数学比例。标准基础单位为 4px(0.25rem)。所有间距值都应为该基础单位的倍数。

令牌值用途
0.52px极细间隙、图标内边距
14px紧凑的行内间距
1.56px小型组件的内部内边距
28px相关项目之间的默认间距
312px紧凑的卡片内边距
416px标准卡片/区块内边距
520px宽裕的内边距
624px区块内边距
832px区块间距
1040px大区块间距
1248px页面区块间距
1664px页面主要分区
2080px主视觉区间距
2496px大型主视觉区间距
间距规则
  • 外部内边距 >= 内部内边距。 卡片的外边距必须等于或大于其内部内边距。内部元素彼此之间的关系应比它们与外部元素之间的关系更紧密。
  • 距离近表示关系密切。 距离更近的元素会被认为彼此相关(格式塔接近律)。有意识地运用间距进行分组或区分。
  • 消除含糊的间距。 如果两个元素之间的间隙可能属于任一元素,那么这个间隙就是含糊的。通过不对称间距明确元素关系。
  • 消除无效区域。 在子元素上使用内边距,而不是在容器上使用外边距。交互项目之间的每个像素都应可点击。
  • 使用一致的增量。 不要随意选取数值,应限制在比例范围内。三个相近的间距(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. 颜色

颜色系统结构

实用的调色板需要包含三类颜色:

  1. **中性色(灰色):**8-10 个色阶。用于文字、背景、面板、表单控件——几乎无处不在。
  2. **主色:**1-2 种颜色,用于主要操作、导航和品牌标识。每种颜色包含 5-10 个色阶。
  3. **强调色/语义色:**红色(破坏性操作/错误)、黄色/琥珀色(警告)、绿色/翠绿色(成功)、蓝色(信息)。每种颜色包含 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 ——会触发重绘(开销较大)
性能排序
  1. CSS 动画/过渡(最佳——在主线程之外运行)
  2. Web Animations API(良好——主线程拥堵时仍能运行)
  3. 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. 响应式设计

断点策略

根据内容需求定义断点,而非设备名称。尽可能减少断点数量。

标记宽度典型用途
sm640px (40rem)横屏手机
md768px (48rem)竖屏平板
lg1024px (64rem)横屏平板 / 小型笔记本电脑
xl1280px (80rem)桌面设备
2xl1536px (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:17:1
大号文本(18px+ 粗体、24px+)3:14.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. 设计令牌

令牌架构

将令牌分层组织:

  1. 原始令牌。 原始值:--blue-500: #3b82f6。这些构成调色板。
  2. 语义令牌。 按用途映射:--color-primary: var(--blue-500)。这些令牌表达含义。
  3. 组件令牌。 限定作用域:--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. 打磨与匠心

优秀与卓越的区别

业余设计与专业设计的区别,在于成百上千个微小而用心的决定不断累积。没有任何一个细节能彻底改变产品,但这些细节汇聚起来,就会带来用户所说的“就是感觉很对”的品质感。

值得践行的设计原则
  1. 统一。 每个部分都应构成连贯体系的一环。不要有孤立的功能。
  2. 普适。 产品应欢迎并服务全球社群,兼顾无障碍体验。
  3. 鲜明。 在设计和功能上都要聚焦。表达要大胆、清晰。
  4. 善于沟通。 动效让产品充满活力。以易于理解的方式传递信息。
  5. 细致入微。 关注每个界面——错误页面、空状态、页脚。
  6. 设计越少越好。 好的设计应尽可能简洁。少即是多。
  7. 让艰苦工作隐于幕后。 下足功夫,让用户轻松使用。
  8. 别让我思考。 界面应该一目了然。如果需要解释,就需要重新设计。
业余与专业的做法
业余做法专业做法
动画时长随意时长经过考量:在 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 工作完成之前,请检查以下几点:

  1. 标志性元素: 你能指出一个体现此设计专属于该产品的元素吗?如果不能,它就只是个模板。
  2. 眯眼测试: 模糊视线。层级是否清晰?主要操作是否一目了然?
  3. 替换测试: 换掉 Logo 后,它可以属于任何其他产品吗?如果可以,设计就缺乏辨识度。
  4. 以全新视角审视: 明天再看,它仍然感觉合适吗?还是有什么地方让你耿耿于怀?

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

评论

支持 Markdown