过去十年,界面设计的话语权从设计软件一路转移到了浏览器。曾经需要切图、标注、导出九宫格阴影的视觉效果,如今几行 CSS 就能实时呈现,还能随主题、随容器、随用户偏好自由变化。现代 UI 设计的分水岭,不在于“好不好看”,而在于“是不是用 CSS 原生能力实现的”。玻璃拟态、新拟态、渐变描边、Bento Grid、流式排版、容器查询、:has() 状态样式——这些听起来很“设计”的词,背后全是可以直接写进样式表的 CSS 技巧。本篇 50 分钟长文,从设计令牌讲到玻璃拟态的光学原理,从流体排版讲到容器查询的自适应哲学,并配有六个可实时调参的实验台。读完它,你不仅能复刻当下最流行的视觉风格,更能理解它们为什么这样写。
现代 UI 的底层逻辑:从“画界面”到“设计系统”
如果把 2015 年的网页和 2026 年的网页放在一起对比,最明显的差异不是配色,也不是字体,而是层次感与秩序感。现代界面几乎都在做同一件事:用有限的几组颜色、圆角、阴影、间距,构建出一套自洽的视觉语言,然后在所有组件里重复使用它。
这套语言在工程上有一个名字:设计令牌(Design Tokens)。它把“主色是什么”“圆角多大”“阴影多深”从设计师的脑子里,搬到了 CSS 自定义属性里。于是换肤、暗色模式、品牌定制,全都变成了改变量值这么简单的事。
现代 UI 技术的四个层次
点击下面每一层,看看它负责什么,以及对应哪些 CSS 能力。
:root 的 CSS 自定义属性里,是全站唯一的“真相来源”。backdrop-filter)、新拟态(双向阴影)、渐变描边(mask 合成)、光晕(多层 box-shadow)。clamp() 流体尺寸、容器查询 @container、Bento 网格,让同一套组件在不同宽度下都成立。:hover、:focus-visible、:has()、prefers-color-scheme、prefers-reduced-motion,让界面回应用户的每一个动作与偏好。为什么“用原生 CSS 做视觉”比图片更好
- 可主题化:图片是死的,CSS 是活的。暗色模式只需要改一组变量。
- 可自适应:一张 2x 切图在 4K 屏幕上会糊,CSS 渐变永远不会。
- 体积更小:一段 20 行的渐变描边代码,大约 400 字节;一张同等效果的 PNG,至少 20KB。
- 可访问:CSS 生成的装饰不进入无障碍树,也不会被读屏软件念出来。
- 可动效:图片没法在 hover 时平滑地改变模糊半径,但
backdrop-filter可以。
接下来的每一节,都会围绕一个具体的现代 UI 技巧展开,并给出可直接抄走的代码。
设计令牌:让整套视觉系统可切换
设计令牌的第一步,是给颜色起“语义化”的名字,而不是“视觉化”的名字。不要写 --blue-500,要写 --color-primary;不要写 --gray-100,要写 --color-surface。原因很简单:当品牌色从蓝变成紫时,你不需要在两百个文件里搜索替换。
:root {
/* 品牌 */
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
/* 表面与文字 */
--color-bg: #f1f5f9;
--color-surface: #ffffff;
--color-text: #0f172a;
--color-muted: #64748b;
--color-border: #e2e8f0;
/* 圆角 */
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-full: 999px;
/* 间距(8pt 栅格) */
--space-1: 4px;
--space-2: 8px;
--space-3: 16px;
--space-4: 24px;
--space-5: 32px;
/* 阴影层级 */
--shadow-sm: 0 1px 2px rgba(15,23,42,.06);
--shadow-md: 0 4px 12px rgba(15,23,42,.08);
--shadow-lg: 0 16px 40px rgba(15,23,42,.12);
}
动手试:一键切换主题
下面这个卡片内部的所有颜色,都来自一组局部变量。点击按钮切换 data-theme,你会看到颜色、边框、阴影全部平滑过渡——这就是令牌化的威力。
.theme-scope {
--color-bg: #ffffff;
--color-text: #0f172a;
--color-border: #e2e8f0;
}
.theme-scope[data-theme="dark"] {
--color-bg: #0f172a;
--color-text: #f8fafc;
--color-border: #334155;
}
/* 组件只引用变量,不关心当前是什么主题 */
.card {
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
transition: background .35s ease, color .35s ease, border-color .35s ease;
}
令牌化的三个常见误区
- 命名过于视觉化:
--blue-500在品牌换色后会变成谎言。坚持按用途命名。 - 层级过深:
--color-brand-primary-hover-strong这种名字没人记得住。控制在两到三层。 - 全部塞进 :root:只有真正全局的东西才放
:root。组件级变量应该定义在组件自己的作用域里。
玻璃拟态 Glassmorphism:不只是加个模糊
玻璃拟态(Glassmorphism)是近几年最出圈的 UI 风格。它的视觉特征是:半透明背景 + 背景模糊 + 细微的亮色描边 + 柔和的投影。但很多人只写了 backdrop-filter: blur() 就以为完成了,结果做出来的卡片又灰又脏,完全没有玻璃的通透感。
真正决定成败的四个参数
- 背景必须有色彩层次:玻璃是“透出后面的东西”。如果背景是纯白,玻璃就是一块灰。必须让玻璃后面有渐变、有光斑、有图案。
backdrop-filter的模糊半径:太小看不出玻璃感,太大则糊成一团。12~20px 是常见的舒适区间。- 饱和度的补偿:模糊会让颜色发灰,所以要在
backdrop-filter里加saturate(160%)甚至更高,把颜色“救”回来。 - 1px 的亮色描边:这是玻璃的“边缘高光”。
rgba(255,255,255,0.3)到0.5之间效果最好。
动手试:玻璃拟态实时调参台
拖动下面的滑块,观察卡片的变化,下方的 CSS 代码会同步更新。建议先试试把模糊拉到 0,你会看到玻璃感瞬间消失——这能帮你建立参数与视觉之间的直觉。
玻璃拟态的完整代码骨架
.glass {
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(15, 23, 42, 0.25);
}
/* 2. 玻璃后面必须有内容可透 */
.glass-stage {
background: linear-gradient(135deg, #6366f1, #ec4899 55%, #f59e0b);
position: relative;
overflow: hidden;
}
/* 3. 背景里的彩色光斑(可选,但效果拔群) */
.glass-stage::before {
content: '';
position: absolute;
width: 180px;
height: 180px;
border-radius: 50%;
background: rgba(56, 189, 248, 0.8);
filter: blur(30px);
top: 8%;
left: 6%;
}
/* 4. 文字加一点点阴影,保证可读性 */
.glass {
color: #fff;
text-shadow: 0 1px 6px rgba(0, 0, 0, 0.25);
}
玻璃拟态的三个陷阱
backdrop-filter 需要实时采样并模糊背后内容,面积越大、数量越多,GPU 压力越高。满屏玻璃卡片是性能灾难。
contain: paint 限制影响范围。
-webkit-backdrop-filter。不加前缀,用户看到的就是一块纯半透明色块。
新拟态 Neumorphism:一束光与一团影
新拟态(Neumorphism)是玻璃拟态的反面:它不透明、不模糊,靠同色系的双向阴影制造“从背景里挤出来”的立体感。左上角一层浅色阴影模拟光源,右下角一层深色阴影模拟投影,元素就像被压进了同一块塑料里。
.neu {
background: #e8edf5;
border-radius: 22px;
/* 右下深色 + 左上浅色 = 凸起 */
box-shadow:
8px 8px 16px #c9d2e0,
-8px -8px 16px #ffffff;
}
/* 按下状态:把阴影翻转到内部 = 凹陷 */
.neu:active {
box-shadow:
inset 6px 6px 12px #c9d2e0,
inset -6px -6px 12px #ffffff;
}
/* 深色主题下,阴影颜色必须整体下沉 */
.neu-dark {
background: #1e293b;
box-shadow:
8px 8px 16px #131c2b,
-8px -8px 16px #29384f;
}
点击任意按钮查看“凹陷”效果
新拟态为什么“叫好不叫座”
新拟态在 Dribbble 上非常好看,但在真实产品里很少大规模使用,原因很实在:
- 对比度天生不足:文字与背景几乎同色,WCAG 对比度要求很难达标。
- 状态难以区分:凸起和凹陷的差异非常微妙,用户常常看不出哪里可以点。
- 极度依赖背景色:换一个背景色,整套阴影就全部作废,必须重算。
- 不适合信息密集界面:在列表、表格、表单里,新拟态会让层级彻底糊掉。
比较务实的用法是:只把它用在少数装饰性控件上,比如播放器的圆形按钮、智能家居的开关面板,同时配合明确的图标和文字标签来保证可用性。
一个关键细节:阴影的颜色不能是纯黑
.bad { box-shadow: 8px 8px 16px #000; }
/* ✅ 深色阴影应当是背景色的“加深版” */
/* 背景 #e8edf5 → 深色阴影 #c9d2e0 */
.good { box-shadow: 8px 8px 16px #c9d2e0; }
/* ✅ 用 color-mix() 自动算出加深色 */
.auto {
--bg: #e8edf5;
background: var(--bg);
box-shadow:
8px 8px 16px color-mix(in srgb, var(--bg) 78%, #000),
-8px -8px 16px color-mix(in srgb, var(--bg) 72%, #fff);
}
渐变、描边与光效:现代 UI 的“高级感”来源
现代 UI 的“高级感”往往来自三个细节:渐变文字、渐变描边、柔和光晕。它们的共同点是:用极少的代码,制造出远超代码量的视觉重量。
渐变文字:一行 background-clip
.grad-text {
background: linear-gradient(90deg, #2563eb, #8b5cf6, #ec4899);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
/* 注意:color 必须写在 background-clip 之后 */
/* 否则部分浏览器会把文字渲染成透明不可见 */
/* 让渐变“流动”起来 */
.grad-text-animated {
background-size: 200% 100%;
animation: gradShift 6s linear infinite;
}
@keyframes gradShift {
to { background-position: 200% 0; }
}
现代 UI 设计
渐变文字是标题的常客,但请节制使用——满屏渐变等于没有重点。
渐变描边:mask 合成的高级用法
CSS 没有 border-image: linear-gradient() 配合圆角的完美方案(border-image 会忽略 border-radius)。真正能兼顾圆角的做法,是用 mask 做“镂空”。
.grad-border {
position: relative;
border-radius: 14px;
background: #fff;
}
.grad-border::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
/* padding 决定描边宽度 */
padding: 2px;
background: linear-gradient(135deg, #2563eb, #8b5cf6, #ec4899);
/* 关键:用两层 mask 相减,只保留边框区域 */
-webkit-mask:
linear-gradient(#000 0 0) content-box,
linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
原理拆解:第一层 mask 只覆盖 content-box(内容区),第二层 mask 覆盖整个盒子。两者用 xor / exclude 做差集,剩下的正好是一圈边框。这是一种非常通用的“局部着色”技巧,也可以用来做渐变下划线、渐变角标。
网格背景与极光光晕
.grid-bg {
background-color: #0f172a;
background-image:
linear-gradient(rgba(148,163,184,.22) 1px, transparent 1px),
linear-gradient(90deg, rgba(148,163,184,.22) 1px, transparent 1px);
background-size: 22px 22px;
}
/* 极光光晕:大半径 radial-gradient + 缓慢漂移 */
.aurora {
background:
radial-gradient(circle at 20% 30%, rgba(56,189,248,.5), transparent 45%),
radial-gradient(circle at 80% 70%, rgba(236,72,153,.5), transparent 45%),
#0f172a;
animation: auroraShift 12s ease-in-out infinite alternate;
}
@keyframes auroraShift {
from { background-position: 0 0, 0 0; }
to { background-position: 40px 20px, -30px -20px; }
}
1px 描边由 mask 合成,圆角完美保留
阴影体系:让层级“说得清楚”
现代 UI 里,阴影不是装饰,而是信息架构的一部分。卡片浮在页面上、模态框浮在卡片上、提示浮在模态框上——用户对“谁在上层”的判断,八成来自阴影。
:root {
/* L0:贴地,几乎无阴影 */
--shadow-0: none;
/* L1:卡片 */
--shadow-1: 0 1px 2px rgba(15,23,42,.06), 0 2px 6px rgba(15,23,42,.05);
/* L2:悬浮卡片 / 下拉 */
--shadow-2: 0 4px 10px rgba(15,23,42,.08), 0 10px 24px rgba(15,23,42,.07);
/* L3:模态框 */
--shadow-3: 0 10px 24px rgba(15,23,42,.12), 0 24px 64px rgba(15,23,42,.14);
}
/* 关键:多段阴影叠加,比单段大阴影自然得多 */
/* 近处的小阴影负责“贴地感” */
/* 远处的大阴影负责“高度感” */
阴影的颜色也要跟随主题
/* 深色背景上的黑色阴影完全看不见,卡片会“糊”在一起 */
.card-bad { box-shadow: 0 4px 12px rgba(0,0,0,.1); }
/* ✅ 暗色模式下改用“更深的背景色 + 更低的透明度” */
[data-theme="dark"] .card {
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
}
/* ✅ 更高级:给暗色模式的卡片加一条顶部高光 */
[data-theme="dark"] .card {
border-top: 1px solid rgba(255,255,255,.08);
}
在暗色界面里,用亮色描边代替阴影来区分层级,通常比硬堆阴影更干净。这也是当前很多设计系统的选择。
流体排版:一个 clamp() 顶掉十个媒体查询
过去写响应式字号,要写一堆断点:手机 18px、平板 22px、桌面 28px、大屏 32px。现在,clamp(最小值, 弹性值, 最大值) 一行就能覆盖整个区间,而且过渡是连续的、没有跳变的。
/* 理想值通常用 vw / cqi 这类相对单位 */
.title {
font-size: clamp(1.5rem, 4vw, 3rem);
}
/* 更精细的写法:用斜率控制变化速度 */
/* 视口 320px 时约 20px,视口 1440px 时约 44px */
.hero-title {
font-size: clamp(20px, 1.5rem + 1.2vw, 44px);
}
/* 间距也可以流体化 */
.section {
padding-block: clamp(32px, 6vw, 96px);
gap: clamp(12px, 2.5vw, 28px);
}
/* 圆角、阴影模糊半径同样适用 */
.card {
border-radius: clamp(10px, 1.2vw, 20px);
}
动手试:容器单位 cqi 驱动的流体字号
下面这段文字使用 font-size: clamp(15px, 8cqi, 40px)。cqi 是“容器内联方向尺寸的 1%”,所以文字大小会跟随容器宽度变化,而不是视口。拖动滑块调整容器宽度,观察文字缩放。
流体排版的两个注意点
- 一定要设最小值:
clamp()的第一个参数是安全网。没有它,极端窄屏下文字会小到无法阅读。 - 不要对所有文字都流体化:正文段落用固定字号反而更稳,因为行高与行长需要精确配合。流体化主要用于标题、间距、装饰性尺寸。
容器查询:组件终于能“看自己有多宽”
媒体查询问的是“视口有多宽”,容器查询问的是“我这个容器有多宽”。这个区别看起来很小,实际却是组件化设计的分水岭:一个卡片组件放在主栏里可以是横向布局,放进侧边栏就自动变成纵向布局——同一份代码,两种形态。
.card-wrap {
container-type: inline-size;
/* 只关心宽度,不影响高度计算 */
}
/* 也可以给容器起个名字,便于精确匹配 */
.sidebar {
container-type: inline-size;
container-name: sidebar;
}
/* 2. 在子元素里根据容器宽度写规则 */
@container (max-width: 320px) {
.card {
flex-direction: column;
text-align: center;
}
}
/* 3. 指定容器的写法 */
@container sidebar (min-width: 400px) {
.card { gap: 20px; }
}
动手试:拖动滑块,看卡片自动变形
容器查询 vs 媒体查询:什么时候用哪个
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 页面整体栅格 | @media |
页面级布局本来就该响应视口 |
| 可复用卡片组件 | @container |
组件不知道自己会被放在哪一栏 |
| 导航栏折叠为汉堡菜单 | @media |
取决于设备与视口,与容器无关 |
| 侧边栏内的小组件 | @container |
侧边栏宽度由布局决定,不随视口线性变化 |
| 字号随视口缩放 | clamp() + vw |
全局排版,与容器无关 |
简单记法:页面级用媒体查询,组件级用容器查询。两者不是替代关系,而是分工关系。
:has() 选择器:父级终于能“看见”子级
被戏称为“CSS 的 jQuery”的 :has(),让选择器第一次可以从子元素反向选中父元素。它解决了很多以前必须靠 JavaScript 才能完成的状态样式问题。
.field:has(input:not(:placeholder-shown)) label {
color: var(--primary);
}
/* 场景二:卡片内有勾选时,整张卡片高亮 */
.plan:has(input:checked) {
border-color: var(--primary);
background: #eff6ff;
transform: translateY(-3px);
}
/* 场景三:根据是否有图片,改变布局 */
.card:has(img) { grid-template-rows: auto 1fr; }
.card:not(:has(img)) { grid-template-rows: 1fr; }
/* 场景四:列表里有任何一项被选中,整组加边框 */
.list:has(.item.selected) {
outline: 2px solid var(--primary);
}
:has(*)
动手试:用 :has() 实现套餐选择卡片
点击任意卡片,选中的样式完全由 CSS 的 :has() 控制,没有任何 JavaScript 参与样式切换。
当前选择:基础版
一个必须注意的性能细节
body:has(.loading) .everything { /* ... */ }
/* ✅ 安全:限定在具体的组件容器内 */
.toolbar:has(.menu-open) .toolbar__item { /* ... */ }
/* 经验法则:把 :has() 的作用范围限制在 1~2 层 DOM 之内 */
Bento Grid:用 Grid 排出信息密度
Bento Grid(便当盒布局)的核心是:不同尺寸的卡片拼合成一个规整的矩形区域。它既能承载不同权重的信息,又不会像瀑布流那样参差不齐。实现它只需要 Grid 的 span。
.bento {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 86px;
gap: 10px;
}
/* 主卡片:占 2×2 */
.bento .c1 {
grid-column: span 2;
grid-row: span 2;
}
/* 宽卡片:占 2×1 */
.bento .c2 { grid-column: span 2; }
/* 小卡片:默认 1×1,不需要写任何规则 */
/* 窄屏降级为 2 列 */
@media (max-width: 600px) {
.bento {
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: 78px;
}
}
Grid 主卡片 · 2×2
Bento Grid 的三个设计要点
- 卡片数量越少越好:超过 9 个,视觉重心会散掉。宁可拆成两组。
- 大卡片必须放最重要的内容:用户的视线会自然落在大块区域上,别浪费。
- 所有卡片共用同一套圆角与间距:这是“便当盒”整齐感的来源,不要给某个卡片单独调圆角。
微交互:让界面“回应”用户
现代 UI 与十年前最大的体感差异,往往不在视觉,而在反馈的即时性与细腻度。按钮按下去有轻微的下沉、卡片悬停有柔和的上浮、鼠标位置带出淡淡的高光——这些细节加起来,就是“精致”与“粗糙”的分界线。
.btn {
background: var(--primary);
color: #fff;
border-radius: 10px;
transition:
transform .18s cubic-bezier(.34,1.56,.64,1),
box-shadow .25s ease;
}
/* 悬停:上浮 + 阴影扩散 */
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(37,99,235,.4);
}
/* 按下:回落 + 轻微缩小,模拟物理反馈 */
.btn:active {
transform: translateY(0) scale(.97);
box-shadow: 0 2px 8px rgba(37,99,235,.35);
}
/* 键盘聚焦:只有键盘用户才看到外圈 */
.btn:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 3px;
}
/* 鼠标跟随高光:用 CSS 变量接收坐标 */
.btn::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at var(--mx, 50%) var(--my, 50%), rgba(255,255,255,.45), transparent 55%);
opacity: 0;
transition: opacity .3s ease;
}
.btn:hover::after { opacity: 1; }
鼠标移入试试:按钮会跟随光标位置产生高光
微交互的时长法则
- 按钮反馈:100~150ms。再长就会觉得“迟钝”。
- 悬停过渡:150~250ms。太短显得生硬,太长显得黏腻。
- 弹入效果:250~400ms,配合轻微过冲的缓动曲线。
- 缓动曲线:微交互推荐
cubic-bezier(0.34, 1.56, 0.64, 1),也就是常说的“回弹曲线”。
:root {
--dur-instant: 100ms;
--dur-fast: 150ms;
--dur-base: 220ms;
--dur-slow: 360ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--ease-accelerate: cubic-bezier(0.4, 0, 1, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
}
3D 与透视:一张卡片的“呼吸感”
在现代 UI 里,3D 变换很少用于真正的三维场景,更多是用来制造轻微的纵深感。鼠标移到卡片上,卡片顺着光标方向轻微倾斜,内部元素因为 translateZ 而浮起——这种效果只需要几行 CSS 加十几行 JS。
.tilt-wrap {
perspective: 900px;
}
/* 2. 卡片接受旋转,并保留子元素的 3D 层次 */
.tilt-card {
transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
transform-style: preserve-3d;
transition: transform .15s ease-out;
will-change: transform;
}
/* 3. 内部元素沿 Z 轴浮起 */
.tilt-card .title { transform: translateZ(28px); }
.tilt-card .desc { transform: translateZ(18px); }
/* 4. JS:根据光标位置算出旋转角度 */
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const px = (e.clientX - r.left) / r.width - 0.5;
const py = (e.clientY - r.top) / r.height - 0.5;
card.style.setProperty('--ry', (px * 14) + 'deg');
card.style.setProperty('--rx', (-py * 14) + 'deg');
});
card.addEventListener('pointerleave', () => {
card.style.setProperty('--rx', '0deg');
card.style.setProperty('--ry', '0deg');
});
三个必须注意的细节
- 旋转角度别超过 15 度:超过就会显得浮夸,甚至让文字难以阅读。
- 过渡时间要短:跟手的感觉来自
0.1s~0.2s的过渡。太长会“拖泥带水”。 - 只在指针设备上启用:触屏没有 hover,用
@media (hover: hover)包起来更稳妥。
@media (hover: hover) and (pointer: fine) {
.tilt-card { /* 3D 相关样式 */ }
}
暗色模式:不是简单地把颜色反过来
暗色模式最常见的错误做法,是把所有颜色做一次 invert()。结果就是:纯白变成纯黑、彩色变成刺眼的高饱和、阴影全部消失。真正专业的暗色模式,需要重新设计一整套取值规则。
/* 纯黑 (#000) 与亮色文字对比过强,长时间阅读会累 */
[data-theme="dark"] {
--color-bg: #0f172a; /* 不是 #000 */
--color-surface: #1e293b;
--color-text: #e2e8f0; /* 不是 #fff */
}
/* 2. 主色要提亮、降饱和 */
/* 亮色模式:#2563eb 在深色背景上会显得沉闷 */
/* 暗色模式:#60a5fa 更亮、更清爽 */
[data-theme="dark"] {
--color-primary: #60a5fa;
}
/* 3. 阴影改为“更深”,或用描边替代 */
[data-theme="dark"] .card {
box-shadow: 0 4px 16px rgba(0, 0, 0, .5);
border: 1px solid rgba(255, 255, 255, .08);
}
/* 4. 图片和图标要降低亮度,避免刺眼 */
[data-theme="dark"] img {
filter: brightness(.88) contrast(1.05);
}
用 data-mode 控制作用域内的令牌,页面其余部分不受影响
跟随系统 + 手动覆盖
@media (prefers-color-scheme: dark) {
:root { /* 暗色令牌 */ }
}
/* 用户手动选择优先于系统偏好 */
:root[data-theme="light"] { /* 亮色令牌 */ }
:root[data-theme="dark"] { /* 暗色令牌 */ }
/* 让表单控件也跟着变色 */
:root { color-scheme: light dark; }
color-scheme: light dark 这一行非常关键:它会让浏览器自带的滚动条、输入框、下拉菜单也切换到暗色样式。少了它,暗色页面里会突兀地冒出一个白色滚动条。
无障碍:现代 UI 的底线而不是加分项
再漂亮的界面,如果有一群人用不了,它就是失败的。现代 UI 技巧里,有几条无障碍规则必须刻进肌肉记忆。
1. 对比度:正文至少 4.5:1
/* 正文文字:≥ 4.5 : 1 */
/* 大号文字(18.66px 粗体 / 24px 常规):≥ 3 : 1 */
/* UI 组件边界、图标:≥ 3 : 1 */
/* ❌ 常见错误:浅灰文字压在白色卡片上 */
.bad { color: #cbd5e1; } /* 约 1.6:1 */
/* ✅ 安全取值 */
.good { color: #64748b; } /* 约 4.8:1 */
.better { color: #475569; } /* 约 7.5:1 */
2. 焦点可见:永远不要 outline: none
*:focus { outline: none; }
/* ✅ 正确:只在鼠标点击时隐藏,键盘聚焦时显示 */
.btn:focus { outline: none; }
.btn:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 3px;
}
/* :focus-visible 的智能之处:鼠标用户看不到外圈,键盘用户看得到 */
3. 减少动效:尊重 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;
scroll-behavior: auto !important;
}
}
/* 更优雅:保留淡入淡出,去掉位移与旋转 */
@media (prefers-reduced-motion: reduce) {
.reveal {
transform: none;
transition: opacity 150ms linear;
}
.tilt-card, .parallax {
transform: none !important;
}
}
性能与兼容性:漂亮的代价要可控
前面介绍的技巧里,有相当一部分是“昂贵”的。backdrop-filter 要实时采样背景、box-shadow 要额外绘制、大面积渐变要重新光栅化、3D 变换要生成合成层。用对了是加分,用错了就是卡顿。
| 技巧 | 主要开销 | 代价 | 控制建议 |
|---|---|---|---|
transform / opacity 动画 |
合成 | 低 | 放心用,是现代 UI 动效的基石 |
| CSS 渐变 / 渐变文字 | 绘制(一次性) | 低 | 静态渐变几乎零成本,动态改变渐变色才需注意 |
box-shadow |
绘制 | 中 | 面积越大越贵;动画时改用伪元素 + opacity |
border-radius + overflow: hidden |
绘制 + 裁剪 | 中 | 静态使用没问题,滚动容器内慎用 |
filter: blur() |
绘制 + 合成 | 中 | 小面积可用,避免长时动画 |
backdrop-filter |
采样 + 模糊 + 合成 | 高 | 数量控制在个位数,加 contain: paint 限制范围 |
大面积 mask 合成 |
绘制 + 合成 | 高 | 仅用于静态装饰性元素,不要动画 mask |
3D 变换 + preserve-3d |
合成 + 图层管理 | 高 | 只给少数卡片使用,动画结束后移除 will-change |
兼容性现状速查
/* CSS 自定义属性 —— 全平台,放心用 */
/* Grid / Flex —— 全平台,放心用 */
/* clamp() —— 全平台,放心用 */
/* :focus-visible —— Chrome 86+ / Safari 15.4+ */
/* backdrop-filter —— Chrome 76+ / Safari 9+(需前缀) */
/* :has() —— Chrome 105+ / Safari 15.4+ */
/* 容器查询 @container —— Chrome 105+ / Safari 16+ */
/* 容器单位 cqi —— 随容器查询一起支持 */
/* color-mix() —— Chrome 111+ / Safari 16.2+ */
/* transition-behavior —— Chrome 117+ */
/* @starting-style —— Chrome 117+ / Safari 17.5+ */
/* 1. 先写所有浏览器都能用的基础样式 */
.card {
background: rgba(255, 255, 255, 0.85);
border-radius: 16px;
border: 1px solid rgba(255,255,255,.5);
}
/* 2. 支持时才升级为玻璃效果 */
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.card {
background: rgba(255, 255, 255, 0.18);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
}
}
/* 3. 容器查询的降级:默认用媒体查询的结果 */
.card { flex-direction: row; }
@supports (container-type: inline-size) {
.card-wrap { container-type: inline-size; }
@container (max-width: 320px) {
.card { flex-direction: column; }
}
}
性能优化的四条铁律
- 动画只碰 transform 与 opacity:其他属性的动画都可能触发重排或重绘。
- backdrop-filter 数量要克制:一屏超过 5 个就该重新考虑设计。
- 避免图层爆炸:不要无脑
will-change: transform,图层会吃显存。 - 在真机上测:模拟器反映不出 GPU 与内存的真实压力,中低端安卓机才是试金石。
现代 UI 设计技巧最佳实践清单
把全文的核心结论浓缩成一份可以直接贴在工位上的清单。
- 先建令牌,再写组件:颜色、圆角、间距、阴影全部变量化,主题切换才有意义。
- 令牌命名按用途:用
--color-surface,不要用--gray-100。 - 玻璃拟态必须有色彩背景:纯色背景上的玻璃就是一块灰。
backdrop-filter记得加saturate():模糊会让颜色发灰,饱和度要补回来。- 玻璃卡片数量控制在个位数:这是性能上最容易失控的一项。
- 新拟态只用于装饰性控件:它的对比度天生不适合承载信息。
- 阴影用多段叠加:近处小阴影 + 远处大阴影,比单段大阴影自然得多。
- 暗色模式不要用纯黑:
#0f172a比#000舒服得多。 - 暗色模式记得
color-scheme:否则滚动条和表单控件会突兀地发白。 - 字号间距用
clamp():一个函数顶掉一串媒体查询。 - 组件级响应式用
@container:页面级才用@media。 - 状态样式优先用
:has():能少写一堆 class 切换的 JS。 - 微交互时长 150ms 左右:超过 300ms 就会显得迟钝。
- 3D 倾斜不超过 15 度:并且用
@media (hover: hover)包起来。 - 永远不要
outline: none:用:focus-visible做精细化处理。 - 尊重
prefers-reduced-motion:减少位移与旋转,而不是粗暴地全部关掉。 - 正文对比度至少 4.5:1:这是可读性的硬门槛。
- 新特性用
@supports渐进增强:基础体验要在所有浏览器上成立。
/* 1. 设计令牌 */
:root {
--color-primary: #2563eb;
--color-surface: #ffffff;
--color-text: #0f172a;
--color-muted: #64748b;
--color-border: #e2e8f0;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--shadow-1: 0 1px 2px rgba(15,23,42,.06), 0 2px 6px rgba(15,23,42,.05);
--shadow-2: 0 4px 10px rgba(15,23,42,.08), 0 10px 24px rgba(15,23,42,.07);
--shadow-3: 0 10px 24px rgba(15,23,42,.12), 0 24px 64px rgba(15,23,42,.14);
--dur-fast: 150ms;
--dur-base: 220ms;
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
color-scheme: light dark;
}
/* 2. 基础卡片 */
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-1);
padding: clamp(14px, 2vw, 22px);
transition: transform var(--dur-base) var(--ease-standard), box-shadow var(--dur-base) var(--ease-standard);
}
.card:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-2);
}
/* 3. 玻璃层 */
.glass {
background: rgba(255,255,255,.18);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255,255,255,.35);
border-radius: var(--radius-lg);
}
/* 4. 焦点与无障碍 */
:focus-visible {
outline: 3px solid #93c5fd;
outline-offset: 3px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
最后的三个提醒
第一,技巧是手段,不是目的。玻璃拟态、新拟态、Bento Grid 都是工具。如果一个界面需要堆上所有流行风格才显得“现代”,那它大概率不现代。真正的现代 UI,是克制、有秩序、且在每个细节上都为使用者考虑过的。
第二,先跑起来,再谈好看。所有视觉增强都应该建立在“基础可用”的前提上。玻璃效果不支持时,半透明背景依然可读;3D 倾斜不支持时,卡片依然是卡片;容器查询不支持时,媒体查询依然兜底。
第三,去真机上试。这篇文章里的每一个实验台,都建议你在自己的项目里复刻一遍,然后拿到中低端手机上看一眼。你会发现,那些在桌面浏览器上丝滑无比的效果,在真实设备上可能完全是另一回事——而这个发现,比读完十篇文章都有价值。
愿你的界面,既好看,又好用,还很快。