1. 从 Basil 案例切入:Material Design 规范到底在解决什么问题
第一次看到「Basil」这个案例名的时候,我下意识以为是某个开源组件库的代号,翻完 Material Design 官方那套设计案例之后才反应过来——它其实是一个用来演示 MD 规范如何落地的完整设计样本,覆盖了从色彩系统、排版层级、组件状态到动效节奏的一整套决策链路。换句话说,Basil 不是一个「好看的界面」,而是一份「为什么这么设计」的说明书。
Material Design(后面统一简称 MD)这套规范从 2014 年推出来到现在,经历过 Material Design 1、2、3 三个大版本,很多设计师和前端同学对它的印象还停留在「卡片 + 阴影 + 悬浮按钮」这种表层符号上。但真正做过完整项目的人都知道,MD 的价值根本不在视觉符号,而在于它把「空间、层级、动效、状态」这几件事抽象成了一套可复用的决策系统。Basil 这个案例之所以值得单独拿出来讲,就是因为它把 MD 里那些平时容易被忽略的细节——比如组件在 hover、focus、pressed、disabled 四种状态下的色彩叠加规则,比如排版层级里 display、headline、title、body、label 五档字号的具体使用边界——全都摆到了台面上。
这篇文章适合三类人看:第一类是刚接触 MD 规范、看官方文档觉得「字都认识但不知道怎么用」的 UI 设计师;第二类是拿到设计稿要还原、但总被吐槽「差了点意思」的前端工程师;第三类是做设计系统、需要把 MD 规范裁剪成自家组件库规范的设计负责人。我会围绕 Basil 这个案例,把 MD 规范里最容易被误读的几个核心机制拆开讲,包括色彩系统的 tonal palette 怎么算、排版层级的字号阶梯怎么选、组件状态层怎么叠、动效曲线怎么配,以及在实际项目里怎么把这些东西落地成可维护的 token。
需要提前说明的是,Basil 案例本身是官方用来演示 MD 3 设计语言的样本之一,它展示的是一个偏内容消费类的应用场景。我在拆解的时候会结合自己做过的一些项目经验做合理补充,凡是官方文档里没有明确写、但实际项目里必须做决策的地方,我都会标注出来这是基于常见实践的推断,而不是规范原文。
2. MD 规范的核心机制拆解:Basil 案例背后的设计逻辑
2.1 色彩系统:为什么 MD 要用 Tonal Palette 而不是直接给色值
很多人第一次看 MD 3 的色彩文档会懵——它不直接告诉你「主色用 #6750A4」,而是给你一套 tonal palette,从 tone 0 到 tone 100 一共 13 个色阶,然后让你根据 light theme 和 dark theme 分别去挑。Basil 案例里最典型的一个细节就是:它的主色调在浅色模式下用的是 tone 40,在深色模式下用的是 tone 80,而不是简单地把同一个色值调亮或调暗。
这个设计背后的逻辑是对比度可控。MD 规范要求正文文字和背景的对比度至少达到 4.5:1,大号文字至少 3:1,这是无障碍的基本门槛。如果你直接拿一个固定色值去适配深浅两套主题,很容易在某一套主题下对比度不达标。而 tonal palette 的每个 tone 都是基于 HCT 色彩空间(Hue、Chroma、Tone)生成的,tone 值直接对应相对亮度,所以你可以通过「浅色模式用 tone 40 做 primary、tone 100 做 on-primary」这种固定映射关系,保证任何色相下对比度都稳定达标。
Basil 案例里我特别注意到它对surface 层级的处理。MD 3 里 surface 不是一个单一颜色,而是一整套从 surface-dim 到 surface-bright 的层级,配合 surface container lowest、low、base、high、highest 五档容器色。Basil 用这套层级来区分「页面背景」「卡片」「弹出层」「对话框」四种空间关系,而不是靠阴影深浅来区分。这一点和 MD 2 时代「阴影越深层级越高」的思路完全不同,是 MD 3 一个很重要的转向。
实际项目里怎么落地?我的做法是先确定一个种子色(seed color),然后用官方提供的算法或工具生成完整的 tonal palette,再把「哪个 tone 用在哪个语义角色上」写成一张映射表。这张表就是后面设计 token 的基础。下面这张表是我在项目里常用的映射关系,可以直接参考:
| 语义角色 | 浅色模式 tone | 深色模式 tone | 典型用途 |
|---|---|---|---|
| Primary | 40 | 80 | 主按钮、选中态、强调文字 |
| On Primary | 100 | 20 | 主按钮上的文字 |
| Primary Container | 90 | 30 | 次级强调容器、标签背景 |
| On Primary Container | 10 | 90 | 容器上的文字 |
| Surface | 98 | 6 | 页面背景 |
| On Surface | 10 | 90 | 正文文字 |
| Surface Variant | 90 | 30 | 分隔线、次级背景 |
| Outline | 50 | 60 | 边框、描边 |
注意:这张表里的 tone 值是 MD 3 官方推荐的默认映射,但并不是死规定。如果你的品牌色对比度天生就高,可以适当调整,但调整之后一定要用对比度检测工具重新验证,别凭肉眼判断。
2.2 排版层级:五档字号不是随便分的
Basil 案例的排版部分是我觉得最值得反复看的地方。MD 3 把排版分成五档:Display、Headline、Title、Body、Label,每档又分 Large、Medium、Small 三个尺寸,一共 15 个样式。很多人看到这个会觉得「太多了记不住」,但实际上这 15 个样式对应的是信息层级的 15 种典型场景,不是让你随便挑的。
我拿 Basil 案例里的实际用法举例。它的页面大标题用的是 Headline Large(32sp),而不是 Display——因为 Display 那三档(57sp、45sp、36sp)是留给「营销页首屏大标题」这种场景的,日常应用界面里用 Display 会显得过于张扬。卡片标题用的是 Title Medium(16sp,字重 500),正文用的是 Body Medium(14sp,字重 400),按钮和标签用的是 Label Large(14sp,字重 500)。这套组合下来,整个界面的信息层级非常清晰,用户扫一眼就知道哪里是标题、哪里是正文、哪里是可点击元素。
这里有个特别容易被忽略的细节:字重和字号是配套使用的。MD 规范里 Title 和 Label 默认字重是 500(Medium),Body 默认是 400(Regular),Headline 和 Display 默认是 400。这个搭配不是随便定的,是因为字号越小越需要靠字重来维持可读性,字号越大越不需要加粗。我见过很多设计稿把正文也加粗到 500,结果整页文字糊成一片,层级反而消失了。
还有一个实操层面的坑:sp 和 dp 的区别。MD 规范里字号单位用的是 sp(scale-independent pixels),行高和间距用的是 dp。sp 会跟随用户的系统字体大小设置缩放,dp 不会。所以如果你在代码里把字号写死成 px,用户调大系统字体之后你的界面就会错乱。Basil 案例里所有文字都用了 sp,这是必须遵守的。
2.3 组件状态层:hover、focus、pressed 到底叠多少透明度
这是我觉得 MD 规范里最「反直觉」但最重要的一部分。Basil 案例里有个按钮,在默认、hover、focus、pressed 四种状态下颜色是不一样的,但差异非常微妙。很多人做设计的时候会直接改按钮的背景色,但 MD 的做法是在原有背景上叠加一层状态层(state layer),状态层的颜色是 on-surface 或 primary,透明度按状态区分。
具体数值是这样的:hover 叠 8%,focus 叠 10%,pressed 叠 10%,dragged 叠 16%。这几个数字我第一次看到的时候也觉得「这么低能看出来吗」,但实际做出来之后发现,正是这种微妙的差异让交互反馈显得「高级」——如果透明度叠到 20% 以上,按钮颜色变化太剧烈,反而显得廉价。
Basil 案例里还有一个细节:disabled 状态不是叠状态层,而是降低整体不透明度。文字和图标降到 38%,容器降到 12%。这个和状态层的逻辑是分开的,因为 disabled 表达的是「不可用」,而不是「被交互」,语义不同,处理方式也不同。
实操心得:状态层这个机制在代码里实现的时候,最优雅的方式是用 CSS 的
::before伪元素或者一层绝对定位的 overlay,而不是去改背景色。这样组件的基础色和状态色是解耦的,换主题的时候只需要改基础色,状态层自动适配。
2.4 动效曲线:为什么 MD 不用 ease-in-out
Basil 案例里的动效看起来很「顺」,但如果你去扒它的曲线参数,会发现它用的不是 CSS 默认的ease或ease-in-out,而是 MD 自己定义的一套曲线,叫emphasized、standard、decelerate、accelerate四种。
这四种曲线的用途是这样的:standard用于「元素在屏幕内移动」,cubic-bezier(0.2, 0, 0, 1);emphasized用于「元素进入或离开屏幕」,cubic-bezier(0.2, 0, 0, 1) 但时长更长;decelerate用于「元素进入屏幕」,cubic-bezier(0, 0, 0, 1);accelerate用于「元素离开屏幕」,cubic-bezier(0.3, 0, 1, 1)。
为什么不用 ease-in-out?因为 ease-in-out 的加减速是对称的,而真实世界里物体的运动往往不是对称的——一个东西从屏幕外飞进来,应该是快速进入然后缓慢停下(decelerate),而不是先慢后快再慢。MD 这套曲线就是模拟这种物理直觉。
时长方面,MD 3 推荐的数值是:小元素(如按钮、图标)100-200ms,中等元素(如卡片、菜单)200-300ms,大元素(如全屏转场)300-500ms。Basil 案例里按钮的按压反馈用的是 100ms,卡片展开用的是 300ms,页面转场用的是 400ms,基本符合这个规律。
3. 从 Basil 案例到实际项目:MD 规范的落地流程
3.1 第一步:确定种子色并生成完整色彩系统
落地 MD 规范的第一步不是画界面,而是定色彩系统。我的做法是先和品牌方确认一个种子色,然后用 HCT 色彩空间生成完整的 tonal palette。这里有个细节:种子色不一定要直接用品牌主色,因为品牌主色往往饱和度很高,直接生成 tonal palette 之后,tone 40 可能会偏艳,用在按钮上会显得刺眼。我的经验是把品牌色的 chroma 适当降低,生成出来的 palette 会更耐看。
生成 palette 之后,按照前面那张映射表把语义角色填进去。这一步建议做成一份 JSON 或 CSS 变量文件,后面所有组件都从这里取色,不要在任何地方写死色值。Basil 案例之所以能同时支持浅色和深色两套主题,就是因为它的色彩是语义化的,而不是硬编码的。
:root { --md-primary: #6750A4; --md-on-primary: #FFFFFF; --md-primary-container: #EADDFF; --md-on-primary-container: #21005D; --md-surface: #FEF7FF; --md-on-surface: #1D1B20; --md-surface-variant: #E7E0EC; --md-outline: #79747E; } [data-theme="dark"] { --md-primary: #D0BCFF; --md-on-primary: #381E72; --md-primary-container: #4F378B; --md-on-primary-container: #EADDFF; --md-surface: #141218; --md-on-surface: #E6E0E9; --md-surface-variant: #49454F; --md-outline: #938F99; }3.2 第二步:建立排版 token 并绑定到组件
色彩系统定完之后,第二步是排版。我建议把 15 个排版样式全部定义成 token,命名规则用「md-display-large」「md-headline-medium」这种,然后在组件里引用。这样做的好处是,如果后面要调整某个层级的字号,只需要改一处。
Basil 案例里有个做法我觉得很值得学:它把排版 token 和组件做了绑定。比如按钮组件默认用 Label Large,卡片标题默认用 Title Medium,列表项主文字用 Body Large、副文字用 Body Medium。这种绑定关系写进组件规范之后,设计师和前端就不会在「这个标题该用哪档字号」上反复扯皮了。
| 组件 | 默认排版样式 | 字号/行高 | 字重 |
|---|---|---|---|
| 页面大标题 | Headline Large | 32/40 | 400 |
| 卡片标题 | Title Medium | 16/24 | 500 |
| 列表主文字 | Body Large | 16/24 | 400 |
| 列表副文字 | Body Medium | 14/20 | 400 |
| 按钮文字 | Label Large | 14/20 | 500 |
| 标签/徽章 | Label Small | 11/16 | 500 |
3.3 第三步:组件状态层的统一实现
状态层这块,我强烈建议在项目初期就统一实现,不要每个组件各写各的。我的做法是封装一个通用的状态层 mixin 或者组件,所有可交互元素都复用它。
.state-layer { position: relative; overflow: hidden; } .state-layer::before { content: ""; position: absolute; inset: 0; background: currentColor; opacity: 0; transition: opacity 100ms cubic-bezier(0.2, 0, 0, 1); pointer-events: none; } .state-layer:hover::before { opacity: 0.08; } .state-layer:focus-visible::before { opacity: 0.10; } .state-layer:active::before { opacity: 0.10; } .state-layer:disabled::before { opacity: 0; } .state-layer:disabled { opacity: 0.38; pointer-events: none; }这段代码里有个细节:focus-visible而不是focus。因为鼠标点击也会触发focus,如果用focus的话,鼠标点击按钮之后状态层会一直保持 10% 的透明度,看起来像按钮被选中了。focus-visible只在键盘导航时触发,这才是符合无障碍预期的行为。
3.4 第四步:动效曲线的统一配置
动效这块,我建议把四条曲线定义成 CSS 变量,然后在所有 transition 和 animation 里引用。这样后面如果要调整动效风格,改一处就行。
:root { --md-easing-standard: cubic-bezier(0.2, 0, 0, 1); --md-easing-emphasized: cubic-bezier(0.2, 0, 0, 1); --md-easing-decelerate: cubic-bezier(0, 0, 0, 1); --md-easing-accelerate: cubic-bezier(0.3, 0, 1, 1); --md-duration-short: 100ms; --md-duration-medium: 300ms; --md-duration-long: 500ms; }注意:MD 3 的 emphasized 曲线其实有两段,官方文档里给的是一个带中间控制点的版本。如果项目里用 CSS 实现,简化成上面这个版本在大多数场景下视觉差异不明显,但如果做精细的转场动画,建议用官方完整参数。
4. 常见问题与排查技巧实录
4.1 色彩对比度不达标怎么办
这是最高频的问题。表现是:设计稿看起来没问题,但用对比度检测工具一测,正文文字和背景的对比度只有 3.8:1,达不到 4.5:1 的门槛。原因通常是 surface 和 on-surface 的 tone 差不够大。
排查思路是这样的:先确认你的 surface 用的是哪个 tone,on-surface 用的是哪个 tone。浅色模式下,surface 是 tone 98,on-surface 是 tone 10,理论上对比度是够的。如果不够,大概率是你把 surface 调成了 tone 95 或者更低,或者把 on-surface 调成了 tone 30 或更高。解决办法是把 surface 往 98 靠、on-surface 往 10 靠,或者直接换用 surface-variant 和 on-surface-variant 这组搭配。
4.2 深色模式下阴影完全看不见
MD 3 在深色模式下基本不用阴影来表达层级,而是用 surface container 的五档容器色。如果你在深色模式下还在用box-shadow,会发现阴影几乎不可见,因为深色背景上黑色阴影没有对比。
正确的做法是:深色模式下用 surface container lowest、low、base、high、highest 这五档颜色来区分层级。数值上,这五档对应的 tone 大概是 4、10、12、17、22(具体数值会随主题色变化)。层级越高,tone 越高,颜色越亮。
4.3 状态层叠加后颜色发灰
这个问题通常出现在彩色按钮上。比如一个 primary 色的按钮,hover 的时候叠了一层 on-surface 的状态层,结果颜色变得灰蒙蒙的。原因是 on-surface 在浅色模式下是接近黑色的深色,叠在彩色上会降低饱和度。
MD 规范里其实有说明:状态层的颜色应该用 on-{component} 而不是 on-surface。也就是说,primary 按钮的状态层应该用 on-primary,而不是 on-surface。这样叠加之后颜色变化更自然。如果你的按钮是 primary container 色,状态层就用 on-primary-container。这个细节很多实现都搞错了。
4.4 排版层级混乱导致界面「没有重点」
这是设计层面的问题,表现是整页文字看起来都差不多大,用户扫视的时候找不到重点。原因通常是设计师只用了 Body 和 Title 两档,没有用 Headline 来拉开层级,或者把太多元素都设成了 Title。
解决办法是回到信息架构层面,先明确页面上有哪几个层级的信息:页面级标题、区块级标题、卡片级标题、正文、辅助文字。然后分别对应到 Headline、Title、Body、Label。一个页面上 Headline 最多出现一次,Title 可以出现多次但不要超过 5 个,这样层级自然就清晰了。
4.5 动效时长设置不当导致「卡顿感」
这个问题在前端实现里很常见。表现是动画看起来「一顿一顿的」,或者「慢半拍」。原因通常是时长设置不合理,或者曲线和时长不匹配。
排查的时候先看时长:小元素超过 200ms 就会显得拖沓,大元素低于 300ms 就会显得仓促。再看曲线:进入屏幕的元素用 decelerate,离开屏幕的用 accelerate,屏幕内移动的用 standard。如果曲线用反了,比如进入用 accelerate,就会感觉元素「冲进来然后急刹车」,非常不自然。
| 问题现象 | 可能原因 | 排查方向 | 解决办法 |
|---|---|---|---|
| 对比度不达标 | tone 差不够 | 检查 surface 和 on-surface 的 tone | 调整到 98/10 或换 variant 组 |
| 深色模式阴影不可见 | 用了阴影表达层级 | 检查是否还在用 box-shadow | 改用 surface container 五档 |
| 状态层颜色发灰 | 状态层颜色用错 | 检查用的是 on-surface 还是 on-primary | 改用 on-{component} |
| 排版层级混乱 | 字号档位用太少 | 检查是否只用了 Body 和 Title | 引入 Headline 拉开层级 |
| 动效卡顿 | 时长或曲线不匹配 | 检查时长和曲线组合 | 按元素大小和运动方向匹配 |
5. 把 MD 规范裁剪成自家设计系统的经验
MD 规范很完整,但完整不代表要全用。我做过几个项目之后最大的体会是:MD 规范应该当作参考系,而不是照抄的模板。Basil 案例之所以有价值,不是因为它展示了 MD 的全部功能,而是因为它展示了「在一个具体场景下,哪些规范该用、哪些可以简化」。
比如色彩系统,如果你的产品只有一个品牌色、不需要支持多主题,那 tonal palette 那 13 个 tone 你其实只需要用到其中 6 个左右,剩下的可以砍掉。再比如排版,如果你的产品是工具类应用、信息密度高,那 Display 那三档完全可以不用,从 Headline 开始就够了。
裁剪的原则是:保留语义层,砍掉冗余层。语义层指的是「primary、surface、on-surface」这些角色定义,这些必须保留,因为它们是你换主题、做深色模式的基础。冗余层指的是「同一个语义角色下的多个 tone 选项」,如果你的产品不需要那么精细的控制,可以合并。
还有一个经验是:MD 规范的组件状态和动效部分,建议全盘保留,不要裁剪。因为这两块是「体验一致性」的核心,一旦裁剪,不同组件的交互反馈就会不一致,用户会觉得「这个应用有点糙」。而色彩和排版是可以根据品牌调性调整的,这两块裁剪的空间比较大。
最后分享一个我在实际项目里用的小技巧:把 MD 规范里的所有 token 做成一份 Figma 变量表或者一份 CSS 变量文件,然后在这份文件里标注哪些是「必须遵守」、哪些是「可调整」。这样团队里新来的人一看就知道边界在哪里,不会在「这个颜色能不能改」这种问题上反复问。Basil 案例本身没有提供这份标注,但这是我从多个项目里踩坑之后总结出来的,实测下来能省很多沟通成本。