Timing Charts: A Blueprint For SMIL Animations
TL;DR · AI 摘要
SMIL动画通过Timing Charts实现结构化管理,适合在<img>标签中实现无需JavaScript的SVG动画,但存在标签冗余问题。
核心要点
- SMIL动画可在<img>标签中运行,无需JavaScript
- Timing Charts方法能减少SMIL动画的冗余代码
- SMIL每个标签仅支持单个属性动画,导致代码膨胀
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Timing Charts方法
- SMIL动画特性
- 支持<img>标签
- 无需JavaScript
- 优化方案
- 预规划元素属性
- 描述性ID命名
- 局限性
- 单标签单属性限制
- 代码冗余问题
金句 / Highlights
值得收藏与分享的关键句。
SMIL动画可在<img>标签中运行,无需JavaScript,但需要处理属性冗余问题。
Timing Charts方法通过预规划动画元素和属性,可减少SMIL代码膨胀。
SMIL每个动画标签仅支持单个属性,导致与CSS动画相比代码量增加。
时间轴图表:SMIL 动画的设计蓝图 —— Smashing Magazine
- 2026年8月20日
- 0条评论
时间轴图表:SMIL 动画的设计蓝图
- 15分钟阅读
- CSS , SVG , 设计 , 动画
- 在 Twitter , LinkedIn 上分享
#### 关于作者
Johan 是一名开发者,他仍然对大腿骨上的刻痕最终催生了互联网感到惊讶。他好奇活字印刷术的发明者是否曾想象过……更多关于 Johan 的信息 ↬
#### 邮件通讯
每周前端与用户体验技巧。182,000+ 人信赖。
- 智能界面设计模式,45 节课程 + UX 培训
- 庆祝 1000 万开发者
- SmashingConf Freiburg 2026
- AI 界面设计模式,30 节课程 + UX 培训
- 实时 UX 培训 —— 与 Vitaly Friedman 一起学习智能界面设计模式
发现 SMIL,这是一种常被忽视的 SVG 动画方式,可以在
<img>标签中运行,无需 JavaScript 即可完全控制 SVG 中的所有元素。
我们都知道,网页上的所有内容默认都是一个盒子,但你会发现许多动画的 <div> 都假装成圆形。但如果你曾遇到过真正的 <circle>,就会知道它们有更多优势。身披 SVG 的它们,能融入比 HTML/CSS 的 <div> 更广泛的群体。 <img> 标签有严格的 .html 禁令。
<img> 标签并不像其名称暗示的那样静态。不幸的是,如果你通过 <img> 标签加载 SVG 文件,其中嵌入的 JavaScript 将无法运行,但 CSS 动画却能完美工作。许多 SVG 属性都有对应的 CSS 属性,自 2024 年以来,几何属性已在主流浏览器中得到支持。一些你可能想动画化的属性,比如 viewBox,目前还没有对应的实现。
除了 JavaScript 和 CSS,还有另一种 SVG 动画方式:同步多媒体集成语言(SMIL)。尽管它有一些怪癖,但仍然值得学习。与 CSS 动画类似,SMIL 动画也可以在 <img> 标签中运行,无需 JavaScript 即可完全控制 SVG 中的所有元素。
如果你从未听说过 SMIL 或者需要复习,可以查看 Andy Clarke 的同名文章。然后我们将探讨一种规划动画的方法,使 SMIL 标记更易于管理。
分离与整合
SMIL 存在一个问题:它会迅速变得臃肿。与 CSS 和 JavaScript 不同,你可以在每个关键帧中列出多个属性并轻松复用动画,但每个 SMIL 标签只能针对一个元素,且只能同时控制该元素的一个属性。一个属性可以通过一组值进行动画化。但仍然是一个标签、一个元素、一个属性。要编写一个能运行的颜色和透明度变化的最短方式如下:
<animate
attributeName="fill"
to="someOtherColor"
dur="someDuration"
/>
<animate
attributeName="opacity"
to="someOtherValue"
dur="someDuration"
/>这并不算糟糕,但请考虑需要为动画中包含的每个元素重复这个过程。SMIL 动画很快就会比其 CSS 对应版本更长。
为了简化新动画的启动过程,让我们先规划所有想要动画化的元素和属性,并为每个标签创建描述性 ID 的列表。
动画时间与空间的图表
我喜欢使用所谓的“时间轴图表”来规划动画。时间轴图表本质上是一条线段;有些人选择水平线,有些人则偏好垂直线,这为动画整体提供了一个很好的类比,因为线段可以并行、重叠,或有间隙地依次排列。就像动画本身一样。
目前,我们只关注动画的开始和结束时间。在绘制图表时,我们会忽略中间的线段,而是为每个组件动画绘制一条线,标记其开始和结束。我喜欢用一个圆圈和一个条形来标注时间。你可以用任何方式绘制图表,不需要完全按比例绘制,只要所有构成整体动画的小动画之间的相对时间关系清晰即可。此外,添加持续时间标签是一种简单的技巧,可以避免按比例绘制的麻烦。
以下是一个我通常如何设置包含多个动画的时间轴图表的示例:
查看 Johan Grobler 的 Pen [colorAndOpacityChange [forked]](https://codepen.io/smashingmag/pen/01a016dc-399c-7652-b172-4cd4663746b9) 。
查看 Johan Grobler 的 Pen colorAndOpacityChange [forked] 。
需要注意的重要一点是,时间轴图表的线条是按照动画在时间上的排列方式进行安排的。动画的一个部分紧接着下一个部分,再接着后续的部分,依此类推。它可视化了动画的各个部分如何随着时间一起运行并依次展开。
S(yncbase)MIL
SMIL 的一大部分是同步。毕竟,这个名字本身就体现了这一点。而且,有多种方式可以指定动画何时开始(这里有一个测试用例可以检查你的浏览器支持情况)。其中最有用的方式之一是使用 syncbase 值,它由 SMIL 标签的 ID 后跟 .begin 或 .end,以及一个可选的正或负偏移量组成。
让我们以之前的包含颜色和透明度变化的动画示例为基础。如果我们希望透明度动画在颜色动画结束前 300 毫秒开始,我们可以进行计算。或者,第二个动画可以使用 syncbase 值 colorChange.end - 300ms。这样,两个动画之间的相对时间关系就变得明确。
<!-- 在绝对时间开始 -->
<animate
id="colorChange"
begin="1s"
...
/>
<!-- 相对于 #first 结束时开始 -->
<animate
id="opacityChange"
begin="colorChange.end - 300ms"
...
/>使用 syncbase 值时,动画的开始时间会根据其他动画的 .begin 或 .end 进行定位。正偏移量会将开始时间向右移动(时间上更晚),而负偏移量会向左移动(时间上更早)。
查看 Johan Grobler 的 Pen [syncbase.end [forked]](https://codepen.io/smashingmag/pen/emgwwGJ) 。
查看 Johan Grobler 的 Pen syncbase.end [forked] 。
使用负偏移量时,它们可以指定在文档加载之前或点击发生之前的时间。计算机无法预测未来(至少目前还不能)。它们能做的最好的事情就是将动画跳转到如果计算机提前窥视未来以抢先启动动画的位置。第二个动画只有在有足够的空间时才会从开始到结束运行,换句话说。
查看 Johan Grobler 的 Pen [syncbase.begin [forked]](https://codepen.io/smashingmag/pen/xbgooXq) 。
查看 Johan Grobler 的 Pen syncbase.begin [forked] 。
Syncbase 值不仅可以将动画从 .end 连接到 .begin。选择一个主动画;通常最先想到的动画通常是该组的最佳表现。所有次要动画都可以设置为 begin="primary.begin"。我只使用 ID #primary 来强调这一点。这样,所有其他动画都相对于这个起始点开始。以这种方式堆叠动画可以减少维护成本,例如,如果我们之后想让整个组在不同时间开始。
让我们将这个想法付诸实践,构建一个加载指示器(或旋转图标)。然后我们将探讨如何通过改变各部分之间的相对时间来改变整个动画的效果:
第1步:选择图像方案
浏览器对 prefers-reduced-motion 媒体特性有广泛支持,Val Head 在另一篇文章中对此进行了深入解释。在考虑移动元素时,我们一定要尊重用户的这一偏好。实际上,这应该被视为不可协商的。
在 SMIL 中,遵守用户 prefers-reduced-motion 设置有各种方法,每种方法都有其优缺点。在早期阶段评估哪种方法最适合您的使用场景,可以避免以后可能需要部分重写。
例如,我们可以考虑使用 <picture> 元素而不是普通的 <img>,因为 <picture> 支持多个 <source> 元素,可以在媒体属性中用作减少运动偏好的回退选项。
或者使用一个内联 CSS @media 查询的 SVG 文件,通过 display: none 切换版本。但这种方法可能在各种环境中引发问题。不过浏览器在持续更新,未来这可能不再是问题。
您也可以考虑使用 CSS 背景图像,因为我们可以将该样式包裹在媒体查询中 —— @media (prefers-reduced-motion) —— 为减少运动偏好设置静态图像作为回退。
还有更多可选方案!例如,SVG 的 <view> 元素也可以用于切换运动偏好内容。
或者,如果我们希望将所有内容整合在一起,可以使用 JavaScript .matchMedia() 和实用的 SMIL DOM 接口来控制哪些动画开始,而不是完全替换文件。
对于本例,我将避免任何运动效果,只使用透明度动画,这种动画通常引发的问题更少。对于这种非交互式动画,我们可以将其加载到 <img> 标签中。当添加运动效果时,我们可以采用 <picture> 方案来显示最合适的动画版本。
第2步:绘制图形
我们将制作经典三圆点旋转器的自定义版本:
查看 Johan Grobler 的 [StaticDots [forked]](https://codepen.io/smashingmag/pen/01a016e5-c44d-75b3-bbb2-3eca2f83932d) 示例。
查看 Johan Grobler 的 StaticDots [forked] 示例。
SVG 专家可能可以直接在文本编辑器中完成所有操作。如果您难以可视化标记的渲染效果,我建议使用图形编辑器如 Inkscape。再次强调,Andy Clarke 有一篇关于他优化和结构化自己绘图过程的精彩文章。
注意:Inkscape 有一个需要注意的地方。通过图层窗口设置你期望作为元素 ID 的值实际上会设置 Inkscape 内部使用的元数据属性值。请使用 Inkscape 的对象属性或 XML 编辑器窗口来设置真正的元素 ID。使用不同的编辑器可能会有不同的效果。此外,在 Inkscape 中,请记住在绘图完成后将文件保存为优化后的 SVG 格式,以去除不必要的元数据。
第 3 步:定义动画
好的,我们继续使用不透明度动画的想法。这些点将淡入淡出。我们将为这些动画分别使用独立的 <animate> 标签。总共使用六个标签。
我们的命名方案将非常直接:我们将它们命名为 #fadeIn 和 #fadeOut,为了区分每对标签,我们在标签 ID 后添加 Left、Middle 或 Right 后缀。在创建自己的 ID 时,请尝试遵循对你有意义的命名惯例。
左侧点的淡入 <animate> 标签:
<animate
id="fadeInLeft"
href="#leftDot"
attributeName="opacity"
from="0"
to="1"
...
/>中间点的淡出 <animate> 标签:
<animate
id="fadeOutMiddle"
href="#middleDot"
attributeName="opacity"
from="1"
to="0"
...
/>第 4 步:设置动画时间
我们有无数种方式可以在时间上安排这六个动画。让我们通过几个示例及其时间图表来观察不同排列方式对整体动画视觉效果的影响。
为了缩小选择范围,所有 <animate> 标签都将使用相同的 dur 值,且所有 syncbase 值都不带偏移量。
对于来自从左到右阅读文化背景的人来说,点按照相同模式出现在屏幕上会显得自然。我们也可以从所有点在动画结束时同时淡出开始:
查看 Johan Grobler 的 [dotsVersion1 [forked]](https://codepen.io/smashingmag/pen/01a016ec-0b1b-731e-bf44-2dd45b7c075f) 示例。
查看 Johan Grobler 的 dotsVersion1 [forked] 示例。
syncbase 值会错开淡入时间,并在点消失后重新启动循环:
<animate
id="fadeInLeft"
...
begin="0s; fadeOutLeft.end"
/>
<animate
id="fadeInMiddle"
...
begin="fadeInLeft.end"
/>
<animate
id="fadeInRight"
...
begin="fadeInMiddle.end"
/>由于所有淡出动画同时结束,选择哪一个来重启循环是任意的。在这里我们将 #fadeOutLeft 视为主动画,并通过 syncbase 值 fadeOutLeft.begin 将其他淡出动画与它同步。稍后如果我们想调整淡出时间,只需更改 #fadeOutLeft 的启动时间。
<animate
id="fadeOutLeft"
...
begin="fadeInRight.end"
/>
<animate
id="fadeOutMiddle"
...
begin="fadeOutLeft.begin"
/>
<animate
id="fadeOutRight"
...
begin="fadeOutLeft.begin"
/>其他时间安排方案
除了整体淡出,我们也可以像淡入一样错开淡出:
<animate
id="fadeOutMiddle"
...
begin="fadeOutLeft.end"
/>
<animate
id="fadeOutRight"
...
begin="fadeOutMiddle.end"
/>不添加偏移量的情况下,我们可以选择几个时间点来启动 #fadeOutLeft。如果它在 fadeInRight.end 时启动:
查看 Johan Grobler 的 [dotsVersion2 [forked]](https://codepen.io/smashingmag/pen/01a016ef-1be0-7359-a8ce-1d45194ed50a) 示例。
查看 Johan Grobler 的 dotsVersion2 [forked] 示例。
视觉效果通过向上移动一个位置,并在 fadeInMiddle.end 时开始 #fadeOutLeft 而发生微妙变化:
查看 Johan Grobler 的示例 [dotsVersion3 [forked]](https://codepen.io/smashingmag/pen/01a016ef-f4d9-745b-a89f-2ff35d898589) 。
查看 Johan Grobler 的示例 dotsVersion3 [forked] 。
从图表中可以看出,我们可以尝试进一步将位置上移至 fadeInLeft.end :
查看 Johan Grobler 的示例 [dotsVersion4 [forked]](https://codepen.io/smashingmag/pen/01a016f1-07f8-732a-966d-c295618c60d7) 。
查看 Johan Grobler 的示例 dotsVersion4 [forked] 。
如果我们从淡出效果开始序列:
查看 Johan Grobler 的示例 [dotsVersion5 [forked]](https://codepen.io/smashingmag/pen/01a016f1-b6a5-7078-a73c-217459f53e20) 。
查看 Johan Grobler 的示例 dotsVersion5 [forked] 。
你可能更倾向于从中间的点开始:
查看 Johan Grobler 的示例 [centerFirstDots [forked]](https://codepen.io/smashingmag/pen/01a016f2-5f78-70a2-a948-f030c147fbe3) 。
查看 Johan Grobler 的示例 centerFirstDots [forked] 。
在迭代动画时,时间轴图表是跟踪工作进度的绝佳方式,它们还能实现不同版本之间的视觉对比。通过绘制时间轴图表,你甚至可能发现动画各部分之间的时间规律,这些规律你可能在没有图表的情况下会忽略。
第 5 步:添加更多动画
随着你对更多元素和属性进行动画处理,跟踪何时开始变得越来越困难。为了展示时间轴图表如何帮助你理清思路,让我们在基础旋转器上进行扩展:
查看 Johan Grobler 的示例 [staticDotsWithClipPaths [forked]](https://codepen.io/smashingmag/pen/01a016f4-f99c-7779-b526-1265ecf5619b) 。
查看 Johan Grobler 的示例 staticDotsWithClipPaths [forked] 。
我为每个点添加了一个 <rect>。我们将这些矩形移动到 <clipPath> 标签中,并移除 fill="white"。随着动画运行,矩形会移动到点上,从而提供一种不同于通过动画 stroke-dashoffset 的描边动画方法。
虽然三个点只需要一个 <clipPath>,但它为文档增加了结构,将它以及类似的标签包裹在 <defs> 标签中是一种良好的实践:
<defs>
<clipPath id="dotsClipPath">
<!-- 这里以及之后使用的矩形几何形状和坐标取决于其父 <svg> 元素所使用的 viewBox -->
<rect
id="clipPathLeftRect"
width="2" height="2"
x="1" y="6"
/>
<rect
id="clipPathMiddleRect"
width="2" height="2"
x="4" y="2"
/>
<rect
id="clipPathRightRect"
width="2" height="2"
x="7" y="6">
</clipPath>
</defs>记得为 <circle> 设置裁剪路径。可以通过 CSS 或使用 clip-path 属性:
<circle
id="leftDot"
...
clip-path="url(#dotsClipPath)"
/>
<circle
id="middleDot"
...
clip-path="url(#dotsClipPath)"
/>
<circle
id="rightDot"
...
clip-path="url(#dotsClipPath)"
/>由于点现在添加了描边,为了保持其大小不变,我们需要通过从 r 中减去描边宽度的一半来补偿:
<circle
...
r="0.9"
stroke-width="0.2"
...
/>这就是图形所需的所有更改。查看带有时间轴图表的动画版本,然后我们将更详细地探讨动画中所做的更改:
查看 Johan Grobler 在 CodePen 上的 [clipPathDots [forked]](https://codepen.io/smashingmag/pen/01a016f8-73b0-707a-b21a-7aa07fb194b2) 示例。
查看 Johan Grobler 的 clipPathDots [forked] 示例。
新增了一个动画 #moveClipPathLeft,用于启动整个序列。为了调整动画节奏,在淡出效果结束与循环重启之间设置了 1s 延迟:
<animate
id="moveClipPathLeft"
href="#clipPathLeftRect"
attributeName="y"
from="6"
to="4"
begin="0s; fadeOutLeft.end + 1s"
fill="freeze"
/>虽然可以使用 <animateTransform> 移动矩形,但为了实现动画平滑重启,需要将矩形移回初始位置。如果选择通过 transform 属性实现动画,需要考虑哪些标签支持动画以及需要设置哪些数据类型。
为了增加变化,中间点的 <rect> 向下移动:
<animate
id="moveClipPathMiddle"
href="#clipPathMiddleRect"
attributeName="y"
from="2"
to="4"
begin="moveClipPathLeft.end"
fill="freeze"
/>我们使用 fill-opacity 属性替代 opacity 属性实现淡入效果,并与矩形的 <rect> 移动完成时间同步:
<animate
id="fadeInLeft"
href="#leftDot"
attributeName="fill-opacity"
to="1"
dur="1s"
begin="moveClipPathLeft.end"
fill="freeze"
/>
<animate
id="fadeInMiddle"
href="#middleDot"
...
begin="moveClipPathMiddle.end"
...
/>
<animate
id="fadeInRight"
href="#rightDot"
...
begin="moveClipPathRight.end"
...
/>淡出效果仍然使用普通 opacity 属性,使填充和描边同时淡出。由于该结构设计为使用组淡出效果,可以在多个位置优化代码。其中一项优化是移除 fill="freeze",让动画结束后自动将点的透明度重置为初始值:
<animate
id="fadeOutLeft"
href="#leftDot"
attributeName="opacity"
to="0"
dur="1s"
begin="fadeInRight.end"
/>
<!-- 仍会将 #fadeOutLeft 视为主要动画,并与其他动画的开始时间同步 -->
<animate
id="fadeOutMiddle"
href="#middleDot"
...
begin="fadeOutLeft.begin"
/>
<animate
id="fadeOutRight"
href="#rightDot"
...
begin="fadeOutLeft.begin"
/>对于使用了 fill="freeze" 的 <animate> 标签,我们将使用 <set> 标签重置这些属性的初始值。为了简化图表,我将这些标签合并展示。由于这些 <set> 标签没有持续时间,我在图表中将开始和结束标记绘制在相同位置。
重置左侧点的 fill-opacity:
<set
href="#leftDot"
attributeName="fill-opacity"
to="0"
begin="fadeOutLeft.end"
fill="freeze"
/>如果在淡出效果中使用 fill="freeze",需要为每个点添加额外的 <set> 来重置中间点的透明度:
<set
href="#middleDot"
attributeName="opacity"
to="1"
begin="fadeOutLeft.end"
fill="freeze"
/>最后需要将裁剪矩形移回初始位置。对于右侧 <rect>:
<set
href="#clipPathRightRect"
attributeName="y"
to="6"
begin="fadeOutLeft.end"
fill="freeze"
/>这仅仅是众多可能的时序变化之一,而你尝试其他变化所需的大部分内容,就像我们之前在基础旋转器中所做的那样,已经准备就绪。你或许可以尝试自己设计几种替代时序方案。
定时图表的优势
总结来说,我们已经认识到,平衡动画的不同阶段在最佳情况下可能颇具挑战性,而在最坏情况下则可能难以实现。每当动画涉及多步骤且超过一两个步骤时,这实际上就成为一种编排工作。你几乎是在构建一个由标记构成的鲁布·戈德堡机械装置。而使用定时图表是一种我所采用的策略,我希望它也能在你的项目中发挥作用。定时图表为你提供了预期结果和时间节点的概览,使你能够以一种不仅有助于规划代码,还能让未来的更新和维护变得更加轻松的方式进行规划。
尽管定时图表无法降低标记的复杂性,但它可以清晰地展示何时应发生哪些事件。定时图表绝不仅限于SMIL动画。不幸的是,syncbase值存在局限性,但即使你采用其他实现动画的方法,它们仍然能够提供帮助。
进一步阅读
我强烈推荐你查看Andy在Smashing Magazine上发表的其他文章。其中甚至还有更多关于如何结合CSS和SVG的内容。
Yosra Emad撰写了一篇关于如何使用CSS创建多步骤动画的出色文章,阅读时你或许可以尝试绘制一些定时图表。
当你准备在定时图表中添加那些中间线时,Nash Vail关于缓动的文章将深入探讨缓动曲线的细节。
你可能还对测试浏览器的SVG实现感兴趣,以了解哪些功能得到了支持。
(gg, yk)
探索更多内容
Smashing Newsletter:每周发送到你的邮箱,涵盖前端与用户体验的实用建议。
前端与用户体验研讨会:在线进行,包含实用技巧、实时课程、视频录像和友好的问答环节。
TypeScript 50课:全面讲解TypeScript,包含代码解析和示例。还有其他印刷书籍。