使用和管理图标

选择图标技术

图标存储和渲染是不同的选择。例如,SVG 精灵可以为 CSS 图标系统提供图标,也可以通过 <use> 进行引用。

  • CSS 图标系统:在需要由 CSS 选择何时显示图标以及显示哪个图标时使用。对于单标,请使用遮罩;对于全彩图标,请使用背景图片。
  • SVG 与 <use>:当符号的内置样式选项足够时,可用于重复使用符号。CSS 无法任意设置所引用 SVG 内容的样式或为其添加动画效果,但如果符号设计为使用继承的属性和自定义属性,则可以传入这些属性。例如,路径可以使用 fill="var(--icon-accent, currentColor)",让 CSS 为图标的该部分着色。
  • 内嵌 SVG:在需要对 SVG 内容进行任意样式设置或动画处理时使用。
  • <img> 搭配 CSS 滤镜:避免使用此方法为图标重新着色。滤镜无法可靠地将任意 CSS 颜色值应用于图片,因此重新着色过程繁琐且不灵活。

如需做出决定,请思考以下两个问题。CSS 是否需要选择或切换图标(组件默认值、状态更改、主题设置)?如果是,请使用 CSS 图标系统。您是否需要触及图标内部,而不仅仅是颜色?如果是,并且您控制图标来源,请使用自定义属性设计符号并使用 <use>;如果您不控制来源,请内嵌 SVG。第三方图标集很少公开自定义属性,因此对它们使用 <use> 只能获得颜色,几乎没有其他内容。

嵌入式或远程图标

CSS 图标系统和 <use> 都可以从数据 URI、单个文件或精灵中获取图标。嵌入(数据 URI 或内嵌 SVG)可避免请求,并在首次绘制时渲染,但会使 CSS 或 HTML 膨胀,且无法单独缓存,因此适合在每个页面上显示的一小组图标。各个文件会单独缓存并按需加载,每个文件会产生一个请求。精灵图是对整个集合的一个可缓存请求,但无论是否使用,都会下载每个图标,并且 <use> 只能引用来自同一来源的精灵图。对于少数核心图标,建议使用嵌入方式;对于较大的图标集,建议使用精灵图或单独的文件。

如果一个页面一次渲染数千个图标,请对实际页面和素材资源进行基准测试。在压力测试中,内嵌 SVG 在该规模下具有轻微的性能优势,但结果取决于浏览器和实现方式。Cloud Four 的 SVG 图标压力测试

主要方法:CSS 驱动的图标引擎

图标是设计令牌:每个图标都是一个包含图片的自定义属性,任何元素都可以通过设置 --icon-start 或 --icon-end 来显示图标。该引擎结合了三项 CSS 功能:

  • 注册的自定义属性 (@property) 会阻止 --icon-start 和 --icon-end 继承,因此在组件上设置的图标不会级联到其子组件。
  • 容器样式查询 (@container style(...)) 会检测元素何时分配了图标并进行渲染,而无需更改类或标记。
  • CSS 遮罩和 currentColor 会在伪元素中呈现图标,该伪元素会沿用元素的文本颜色。系统会从可继承的 --icon-style-* 属性中读取大小、颜色、间距和垂直偏移量,并提供合理的后备值,因此可以从祖先节点为整个子树重新设置样式。--icon-style- 前缀可防止这些设置与图标令牌本身发生冲突,图标令牌也位于 --icon-* 下。

定义图标设计令牌

使用内嵌 SVG 数据 URI 或根相对网址(例如 url("/icons/trash.svg"))将 SVG 图标和任何 --icon-style-* 替换项定义为可重复使用的自定义属性。从外部 SVG 文件加载图标时,请使用根相对网址或嵌入式数据 URI。通过自定义属性使用的相对网址可以相对于使用图标的样式表进行解析,而不是相对于定义自定义属性的样式表进行解析。如果这些样式表位于不同的目录中,则网址可能会解析为错误的文件。

为了使网址相对于定义图标令牌的样式表,注册属性旨在提供帮助:已注册的 <url> 或 <image> 的计算值会根据声明它的样式表进行解析。请勿依赖于将令牌注册为 <image>,并使用 initial-value 中的网址。Chrome 和 Safari 会根据文档解析 initial-value 网址,因此该网址仅在 Firefox 中有效。Chrome 还会根据文档解析在 :root 上声明的值,除非语法以 <url> 开头。

如果相对网址不可避免(例如不同目录中的样式表),请使用以 <url> 开头的语法注册令牌,并在 :root 上声明实际网址。在每个浏览器中,此路径都会根据声明样式表进行解析:

@property --icon-caret {
  /* The syntax must start with <url>. A syntax that starts with <image> resolves against the document in Chrome. */
  syntax: "<url> | <image> | none";
  inherits: true;
  initial-value: none;
}

:root {
  /* Declared here, the URL resolves against this stylesheet */
  --icon-caret: url("icons/caret.svg");
}

现在,每个图标都位于两个位置,并且有更多的样板,因此只有当项目在不同目录中具有样式表且图标数量可控时,才值得这样做。如果图标数量超过十几个,请使用 CSS.registerProperty 通过脚本注册 --icon-* 令牌(不包括 --icon-style-*),并使用相同的语法,而不是手动编写每个 @property。注册的属性是全局属性,无法在 shadow root 中使用,因此可能不适用于所有项目。如果可以选择根相对网址或数据 URI,它们会更简单,并且在任何地方都适用。

:root {
  /* Optional: override the built-in fallbacks (1em, currentColor, 0.3em, -0.02em) */
  --icon-style-size: 1em;
  --icon-style-spacing: 0.4em;
  --icon-trash: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'%3E%3Cpath d='M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0-1 14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2L4 6h16Z'/%3E%3C/svg%3E");
  --icon-favorite: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z'/%3E%3C/svg%3E");
}

通过令牌渲染图标

元素通过将 --icon-start(前导)或 --icon-end(尾随)设置为上述某个令牌来显示图标。两段 CSS 代码会将该分配转换为渲染的图标。

首先,向 @property 注册 --icon-start 和 --icon-end。"*" 语法接受任何值,而 inherits: false 是重要部分:如果没有它,在按钮上设置的图标会继承到每个后代,并且每个后代也会呈现该图标。

@property --icon-start {
  syntax: "*";
  inherits: false;
}

@property --icon-end {
  syntax: "*";
  inherits: false;
}

其次,容器样式查询会检测何时在元素上设置了该属性,并在伪元素中呈现图标。由于伪元素不继承 --icon-start(它已注册为非继承),因此第一个声明会使用 inherit 显式转发该值。其余声明用于处理大小、遮罩、对齐和间距。由于填充颜色默认为 currentColor,因此图标会采用元素的文本颜色,并遵循其过渡效果。

/* Style query for elements configuring a start icon */
@container style(--icon-start) {
  ::before {
    --icon-start: inherit; /* Forward the value inside the query block */
    content: "" / var(--icon-style-alt, "");
    display: inline-block;
    width: var(--icon-style-size, 1em);
    height: var(--icon-style-size, 1em);
    background-color: var(--icon-style-color, currentColor);
    mask: var(--icon-start) no-repeat center / contain;
    /* Center on the cap height, nudged slightly up for optical balance */
    vertical-align: calc(0.5cap - var(--icon-style-size, 1em) / 2 + var(--icon-style-voffset, -0.02em));
    flex: none;
    pointer-events: none;
    forced-color-adjust: none;
  }

  /* Automatically add spacing ONLY if the element has other text or sibling content */
  :where(:not(.icon, :empty))::before {
    margin-inline-end: var(--icon-style-spacing, 0.3em);
  }
}

每项声明都应有其存在的理由:

  • vertical-align:middle 会与 x 高度的一半对齐,这对于大多数图标集来说明显偏低。以 0.5cap 为中心可使图标中心在 --icon-style-size 增大时保持固定,而较小的负 --icon-style-voffset 可补偿眼睛对图标上方空间的重视程度高于图标下方空间。
  • flex: none:空伪元素的最小尺寸为零,因此在 flex 按钮内,当标签较长时,它会首先缩小,甚至可能缩小到零。图标大小是一项设计决策,不应受布局压力影响。
  • pointer-events: none:防止图标捕获点击或成为 event.target。
  • forced-color-adjust: none:在强制颜色模式下,浏览器会替换 background-color,导致遮罩图标消失。遮罩填充保持不变,currentColor 仍遵循强制文本颜色。
  • content: "" / var(--icon-style-alt, ""):生成内容的替代文本,让仅图标控件通过 --icon-style-alt 从图标获取其无障碍名称(请参阅下文的“仅图标控件”)。默认的空 alt 属性可让辅助技术忽略装饰性图标。

对于尾随图标,请遵循相同的模式,使用 --icon-end 和 ::after,而不是 --icon-start 和 ::before。使用 mask: var(--icon-end),并使用 margin-inline-start 而非 margin-inline-end 来应用间距。

独立元素和仅含图标的元素

对于独立图标或纯图标按钮,请直接将 .icon 应用于空元素(对于纯图标控件,则为按钮本身),并使用 --icon 设置其图标。在这里,--icon 是较高级别的属性;.icon:empty 规则会将其映射到 --icon-start,以用作前导图标:

.icon:empty {
  --icon-start: var(--icon);
}

用法示例

样式表中的组件默认值

将图标作为自定义属性的主要好处是,它们会参与级联。在选择器上设置图标,每个匹配的元素都会获得该图标,而无需更改标记:

.button-delete {
  --icon-start: var(--icon-trash);
}

a[href^="http"]:not([href*="example.com"]) {
  --icon-end: var(--icon-external);
}
<button class="button-delete">Delete item</button>

图标随状态而变化

由于图标只是一个属性,因此状态更改只是普通的 CSS 替换。没有要显示或隐藏的第二个元素,也没有要切换的类:

.button-expand {
  --icon-start: var(--icon-caret-right);
}

.button-expand[aria-expanded="true"] {
  --icon-start: var(--icon-caret-down);
}

呈现方式也是如此:在工具栏等容器上设置 --icon-style-size、--icon-style-color 或 --icon-style-spacing,其中的每个图标都会更新。

具有内嵌样式的单次性图标

任何组件规则都未涵盖的元素可以以内嵌方式设置相同的属性,例如 <button style="--icon-start: var(--icon-trash)">。请将此视为应急方案,而不是默认方案:一旦同一图标在同一种元素上出现多次,请将其移入样式表规则。

仅图标控件

纯图标按钮使用上述 .icon 类和 --icon。使用 aria-label 为按钮指定无障碍名称,或让图标通过 --icon-style-alt 提供无障碍名称;如果没有这两者,CSS 生成的图标就是装饰性的。对于独立的信息型图标,请使用 role="img" 和 aria-label 公开 .icon 元素:

.button-close {
  --icon: var(--icon-close);
  --icon-style-alt: "Close";
}
<!-- Icon-only interactive control, icon and name from the stylesheet -->
<button class="icon button-close"></button>

<!-- Icon-only interactive control, named via aria-label -->
<button class="icon" style="--icon: var(--icon-trash);" aria-label="Delete item"></button>

<!-- Standalone informative icon -->
<span class="icon" style="--icon: var(--icon-favorite);" role="img" aria-label="Favorite"></span>

直接使用 SVG

如果 CSS 图标系统不适用,请将内嵌 SVG 和 <use> 结合使用,而不是作为替代方案。在图标首次出现在网页上时内嵌 SVG,并在每次重复时使用 <use href="#id"> 引用它;本地引用不产生请求,也没有跨域限制。为在多个页面中共享的图标预留一个外部精灵 (<use href="/icons.svg#id">)。

使用 <use> 时,引用的内容会被克隆到影子树中,因此网页 CSS 无法选择其元素。继承的内容包括:color、currentColor 和任何自定义属性。除了 SVG 呈现属性之外,现代 CSS 还可以更改许多几何属性,因此您控制的图标可以准确公开您需要的钩子,例如特定路径上的 stroke-width="var(--icon-stroke, 2)" 甚至 d: var(--icon-path, path(…))。

Safari 不支持 CSS d,因此只有在可以接受此支持差距时,才使用此自定义。如果您无法控制图标,请将其内嵌。

<use> 个值得了解的注意事项:

  • viewBox 必须位于 <symbol> 或引用的 <svg> 上;封装 <use> 的外部 <svg> 不会从引用中获取它,因此请在封装容器上设置 width 和 height,并将 viewBox 与符号一起保留。
  • 样式不会跨越边界:外部精灵图内的 <style> 不会应用于页面,页面样式表也不会应用于引用的树。只有继承的属性和自定义属性会传递。
  • 外部引用仅限同源;跨域精灵图会静默失败。
  • 内嵌到文档中的精灵会共享页面的 id 命名空间,因此请为命名空间符号 ID 命名,以避免冲突。

使用 aria-hidden="true" 标记装饰性 SVG,并为包含控件指定其自己的无障碍名称,就像使用 CSS 图标系统一样。

后备策略

已注册的自定义属性

如果没有 @property,--icon-start 和 --icon-end 的行为就像普通的继承自定义属性一样,因此在元素上设置的图标也会在其后代元素上渲染。为防止出现这种情况,请在样式查询 (@container style(--icon-start) { * { --icon-start: initial; } }) 中重置子元素的属性,这与下文中的 Safari 解决方法相同。

容器样式查询

为了支持不支持容器样式查询的浏览器,请使用类或组件选择器作为后备钩子。在自定义属性中保留图标选择;仅使用内嵌样式选择器会遗漏通过 CSS 规则分配的图标。

:where(.icon-start, .icon:empty)::before {
  content: image-set(linear-gradient(transparent 0 100%), var(--icon-start)) / var(--icon-style-alt, "");
  /* icon styles */
}

:where(.icon-start)::before {
  --icon-start: inherit;
}

:where(.icon:empty)::before {
  --icon-start: var(--icon);
}

:where(.icon-start:not(.icon, :empty))::before {
  margin-inline-end: var(--icon-style-spacing, 0.3em);
}

对于尾部图标,请使用相同的类钩子回退,但使用 .icon-end、--icon-end 和 ::after,而不是 .icon-start、--icon-start 和 ::before。使用 margin-inline-start 而不是 margin-inline-end 为非空内容应用间距。

此 image-set() 模式会在未设置自定义属性时使 content 无效,因此它会回退到 none,并且伪元素会在不添加或移除类的情况下消失。它不会移除伪元素的其他样式,也不会阻止与其他 ::before 或 ::after 规则发生冲突,因此请仅在这些伪元素可用于图标时使用这些钩子。

Safari 浏览器权宜解决方法

Safari 存在两个影响图标的渲染 bug:WebKit #301609 可能会导致图标在用户互动之前无法显示,而 #320220 也会影响图标的渲染。以下解决方法可同时解决这两个问题:

@keyframes webkit-301609 {}

@supports ((-webkit-nbsp-mode: normal) or (-webkit-touch-callout: none) or (font: -apple-system-body)) and (content-visibility: auto) {
  @layer webkit-301609-fix {
    ::before, ::after {
      animation: webkit-301609 0s;
    }
  }
}

@supports 查询仅针对 Safari,而 @layer 可减少动画冲突。

在某些情况下(例如在悬停时更改图标),WebKit bug #325900 可能会导致直接替换 --icon-start 无法生效。这会影响非继承注册属性的权宜解决方法。对于 Safari,请将 --icon-start 注册为继承,并在样式查询中重置后代上的:

@supports ((-webkit-nbsp-mode: normal) or
           (-webkit-touch-callout: none) or
           (font: -apple-system-body)) and (content-visibility: auto) {
  /* Override previous declaration */
  @property --icon-start {
    syntax: "*";
    inherits: true;
  }

  @layer webkit-325900-fix {
    @container style(--icon-start) {
      * {
        --icon-start: initial;
      }
    }
  }
}

重置可防止图标值传播到后代。将其保留在图层中可让未分层的图标声明优先于重置。