CHARLIE SAYS

查理如是说
DATE 2026-08-24
THEME
SERIES / ANGULAR / P-210 · Angular 高级教程

Angular Material 22+ 教程 37:自定义主题 Material Design 3

上一篇跑通了 mat.theme 与明暗切换,本篇回答更硬核的问题:设计稿给的是一组品牌色与中性色,怎么精确落到 Material 组件上?按钮高度想改怎么办?light/dark 两套定制值怎么在 Sass 里组织不失控?答案都指向同一个机制——token 覆盖。M3 主题定制的全部工作,本质上就是”覆盖哪些层级的变量、在哪里覆盖”。

先想清楚:覆盖哪一层

回顾三层的职责,定制动作按”改的东西离设计稿多近”选择抓手:

你想改的抓手影响面
整体配色气质mat.theme 换 palette全部组件
具体颜色值(品牌精确色值)mat.theme-overrides 覆盖 system tokens消费该 token 的所有组件
某个组件的尺寸/形状CSS 覆盖 component tokens单个组件实例或全部实例
局部区域换个强调色作用域内重定义 system tokens子树

原则:优先高层。能用 palette 解决的不写 overrides,能用 system token 解决的不碰 component token——层级越低,升级 Material 时的维护成本越高。

mat.theme-overrides:精修 system tokens

mat.theme 从 palette 生成的 token 值未必与品牌规范逐字节一致。mat.theme-overrides 允许按名字覆盖任意 system token:

@use '@angular/material' as mat;

html {
  @include mat.theme((
    color: (
      primary: mat.$violet-palette,
      tertiary: mat.$orange-palette,
    ),
  ));

  // 品牌精修:只动需要的几个 token
  @include mat.theme-overrides((
    primary: #5b3cc4,              // 主色
    on-primary: #ffffff,           // 主色上的内容色
    primary-container: #e7deff,    // 主色容器(浅底色块)
    on-primary-container: #1d005f, // 容器上的内容色
    surface: #fdf8ff,              // 页面底色
    on-surface: #1d1b20,           // 正文色
  ));
}

key 就是去掉 --mat-sys- 前缀的 token 名。M3 颜色 token 都是成组出现的(Xon-X),覆盖时成组覆盖——只改 primary 不改 on-primary,高对比场景下可能出现”深底深字”。

用 sass:map 管理覆盖表

覆盖项一多,直接写在 mixin 调用里会失控。用 sass:map 把品牌规范做成可组合的数据:

// styles/_tokens.brand.scss
$brand: (
  primary: #5b3cc4,
  on-primary: #ffffff,
  primary-container: #e7deff,
  on-primary-container: #1d005f,
);
// styles/_tokens.light.scss
@use 'sass:map';
@use './tokens.brand';

$light: map.merge(tokens.brand.$brand, (
  surface: #fdf8ff,
  on-surface: #1d1b20,
  surface-container: #f3edf7,
  outline: #79747e,
));
// styles/_tokens.dark.scss
@use 'sass:map';
@use './tokens.brand';

$dark: map.merge(tokens.brand.$brand, (
  surface: #141218,
  on-surface: #e6e0e9,
  surface-container: #211f26,
  outline: #938f99,
));

品牌色是两个主题共享的”不变量”,明暗各自的底色、边框是”变量”——map.merge 的组合正好表达了这个结构。主题入口变得极短:

// styles.scss
@use '@angular/material' as mat;
@use './styles/tokens.light' as light;
@use './styles/tokens.dark' as dark;

html {
  color-scheme: light dark;
  @include mat.theme((
    color: (
      primary: mat.$violet-palette,
      tertiary: mat.$orange-palette,
    ),
  ));
}

// 浅色定制:跟随系统或手动选择
@media (prefers-color-scheme: light) {
  html:not([data-theme]) {
    @include mat.theme-overrides(light.$light);
  }
}
html[data-theme='light'] {
  @include mat.theme-overrides(light.$light);
}

// 深色定制
@media (prefers-color-scheme: dark) {
  html:not([data-theme]) {
    @include mat.theme-overrides(dark.$dark);
  }
}
html[data-theme='dark'] {
  @include mat.theme-overrides(dark.$dark);
}

这四个块覆盖了三态开关(系统浅 / 系统深 / 手动)的全部组合,data-theme 的设置逻辑沿用第 36 篇的 ThemeService,前端行为零新增。

从品牌色生成整套 palette

mat.theme 只能从预置 palette 里选,不能直接”给一个 seed 色生成 tonal palette”——M3 的 tonal 算法发生在 Sass 之外。当设计稿给出的是完整的设计 token(而非单一品牌色)时,官方推荐的工作流是:设计侧用 Material Theme Builder 从 seed 色导出整套 token 清单,前端把导出的值整理成上面的 overrides map。也就是说:palette 选择交给工具,token 落地交给 theme-overrides

组件级 token:动组件的”体检指标”

每个 Material 组件还暴露一组组件变量,控制高度、圆角、内边距等细节。它们以 --mat-<组件名>-* 命名,默认值引用 system tokens。覆盖方式就是普通 CSS 变量赋值:

// 全部填充按钮:40px 高、8px 圆角
html {
  --mat-button-filled-container-height: 40px;
  --mat-button-filled-container-shape: 8px;
}

// 只影响某个区域内的表单控件
.density-panel {
  --mat-form-field-filled-container-height: 48px;
}

判断”我要改的东西是不是组件 token”的方法:打开该组件文档的 Theming/Styling 小节,官方会列出全部变量名。实践中两个高频用法:

// 用法一:卡片圆角统一为 12px(设计规范值)
.app-card {
  --mat-card-elevated-container-shape: 12px;
}

// 用法二:让工具栏使用品牌渐变之外的实色
.app-header {
  --mat-toolbar-container-background-color: var(--mat-sys-surface-container);
}

注意最后一条的写法:component token 的值仍引用 system token。覆盖的是”引用”,不是”值”——这样明暗切换时它依然能跟着变。写死具体色值才是主题漂移的开始。

作用域主题:子树换色

system tokens 是普通 CSS 自定义属性,天然可继承、可重定义。给某个功能区域换一套强调色,只需要在该子树的根上重定义相关 token:

.promo-section {
  // 该子树内所有消费 --mat-sys-primary 的 Material 组件自动变色
  --mat-sys-primary: #b3261e;
  --mat-sys-on-primary: #ffffff;
}
<section class="promo-section">
  <button matButton>这个按钮跟着促销区变红</button>
</section>
<button matButton>这个按钮还是全局主色</button>

营销位、危险操作区(红)、成功态(绿)这类”局部换肤”需求,用作用域主题比写一套新主题便宜得多。但克制使用——页面里出现五种主色时,用户对”主色”的直觉就消失了。

完整的编译管线

把本篇所有机制串起来:

graph LR
    P[palette 选择] --> T[mat.theme 生成全套 system tokens]
    L[light overrides map] --> O
    D[dark overrides map] --> O[mat.theme-overrides 按主题生效]
    T --> CSS[CSS 自定义属性]
    O --> CSS
    CSS --> CT[component tokens 默认引用 system]
    CT --> R[组件渲染]
    SCOPE[子树重定义 - 作用域主题] --> R

定制检查清单

检查项标准
palette 优先没有为”换个整体气质”而手写 20 个 token 覆盖
成组覆盖Xon-X 同改,无深底深字
值不写死component token 的值引用 system token,而非十六进制字面量
业务样式同源自研组件同样消费 --mat-sys-*,不另起炉灶
双主题对称light 与 dark 的 overrides 覆盖相同的 token 集合
覆盖集中管理所有 theme-overrides 收敛在 styles/ 目录,不散落在组件 scss 里

最后一条值得强调:token 覆盖是全局契约,散落在业务组件里等于埋雷。组件 scss 里只允许出现作用域主题(有明确边界)与组件级微调(有注释说明原因)。

下一篇进入具体组件:mat-form-field 这个”表单容器之王”的结构、appearance 与错误展示,以及它如何与 Signal Forms 的 [formField] 配合。

系列导航

← 算法 037:字符串匹配:模式预处理:KMP 算法(Knuth-Morris- 目录 算法 038:字符串匹配:模式预处理:BM 算法 (Boyer-Moore) →
← 返回文章列表