按钮进入悬停状态、表单从无效变为有效、菜单展开,或者脚本给元素增删类名时,浏览器都会重新计算样式。默认情况下,新值会在下一次绘制时直接替换旧值。加上 CSS 过渡后,浏览器会在指定时间内算出一系列中间值,我们看到的“跳变”也就变成了连续变化。
不过,过渡并不意味着界面里的所有东西都要动。链接悬停、自动补全结果这类反馈通常越快越好;卡片翻转、抽屉展开等涉及空间变化的操作,可以用一小段运动说明元素去了哪里。先分清反馈的性质,再决定要不要加过渡。

过渡只负责“怎样从旧状态走到新状态”,并不负责创造新状态。新状态仍然来自伪类、类名、属性选择器、表单状态或脚本造成的样式变化。
要看到一次过渡,至少要满足三个条件。元素先要有一个可计算的旧值,样式变化后还要得到一个不同的新值;这个属性能够插值,过渡时长也必须大于零。条件齐全后,浏览器才会在两个值之间取样,并按指定节奏画出中间状态。
最小示例如下:
.button {
color: #d946ef;
transition: color 200ms ease-in 50ms;
}
.button:hover,
.button:focus-visible {
color: #6d28d9;
}这个例子里,真正变化的是 color。transition 只是提前告诉浏览器:颜色变化持续 200ms,使用 ease-in 曲线,并在触发后等待 50ms。如果某个值不能插值,它仍然会切换到最终状态,只是没有中间过程。也正因为如此,内容和功能不能依赖动画才能成立。
实际写组件时,通常把过渡参数放在元素始终匹配的默认规则里,再把目标值放进按条件匹配的状态规则:
.submenu {
transform: scaleY(0);
transform-origin: top center;
opacity: 0;
transition:
transform 200ms ease-out 50ms,
opacity 160ms linear 50ms;
}
.menu-item:is(:hover, :focus-within) .submenu {
这样写,菜单进入状态时会展开,离开时也会按同一组参数收起。若只在 :hover 中声明 transition,鼠标移入时它确实生效,但鼠标移出后这条声明也没了,收起往往会瞬间完成。除非你明确想要“缓慢出现、立即消失”,否则把过渡写在默认规则里更合适。
这里说的“默认状态”,只是当前没有焦点、没有特定类名或没有勾选时匹配的规则,并不是一个永远不变的初始画面。一次具体变化中,:valid、:invalid、:checked、:focus 都可能成为起点,也可能成为终点。
你可以在下面的实验里分别切换属性、时长、节奏和延迟。操作时留意一件事:改“哪个值”,和规定“这个值怎样变化”,是两套彼此独立的设置。
CSS 过渡有四个长属性,也有一个把它们合在一起的简写属性:
它们可以用在元素以及 ::before、::after 伪元素上,但不会继承;这些属性自身也不能参与动画。
transition-property 指定属性transition-property 可以接收属性名列表,也可以写 all 或 none:
.card {
transition-property: transform, opacity, border-radius;
}只有列出来的属性在发生变化且可以动画时,才会出现渐变。没列出的属性直接切换;不能动画的属性即使出现在列表里,也只是跳到新值,不会连带整条声明一起失效。
all 写起来省事,因为元素上所有可动画的变化都会被捕获,继承而来的变化也算在内。问题在于,后来新增的 width、padding 或阴影可能在不经意间也动起来,甚至持续触发布局计算。组件样式里,我更建议明确列出 transform、opacity、background-color 这类确实需要变化的属性。
如果 all 和具体属性同时出现,后匹配到的项会覆盖前面的设置。可以先写 all,再把例外放到后面:
.panel {
transition-property: all, opacity, border-radius;
transition-duration: 180ms, 100ms, 320ms;
}这样一来,普通属性使用 180ms,opacity 使用 100ms,border-radius 使用 320ms。none 表示不让任何属性过渡,它必须单独出现,不能塞进逗号列表。
如果只想在某种情境下关掉现有过渡,可以覆盖为 transition-property: none,也可以把相应时长设为 0s。前一种写法不再选择任何参与属性,后一种写法则把过程压缩为零时长。
浏览器会通过 TransitionEvent 发出四类事件:
transitionrun:过渡被创建,延迟阶段也算在内。transitionstart:延迟结束,实际插值开始。transitionend:某个长属性成功走到终点。transitioncancel:过渡在完成前被取消。别把一次视觉变化理解成一次 transitionend。border-radius 会展开成四个角的长属性,padding 会展开成四个方向,border 里的宽度和颜色也各自对应四个方向。因此,监听事件时要检查 event.propertyName,不能只按“用户操作了一次”来计数。
const panel = document.querySelector('.panel');
panel.addEventListener('transitionend', (event) => {
if (event.propertyName !== 'opacity') return;
console.log(event.propertyName, event.elapsedTime, event.pseudoElement);
});propertyName 给出刚刚结束的长属性名,elapsedTime 是实际运行的秒数,不包括正延迟,pseudoElement 则说明事件是否来自伪元素。正向过程如果中途被打断,就没有对应的 transitionend;随后的反向过程若完整结束,仍会正常触发事件。
transition-duration 设置时长transition-duration 使用秒 s 或毫秒 ms,单位不能省。即使值为零,也建议明确写成 0s。负时长无效,并会让整条 transition-duration 声明失效。常见的界面反馈可以先从 100ms 到 300ms 之间试起:太短几乎看不出来,太长又会让操作显得迟钝。移动距离较大或过程较复杂时,再按实际效果调整。
不同的目标状态也可以使用不同的进入时长:
input {
transition-property: background-color;
}
input:invalid {
background-color: #fecaca;
transition-duration: 1s;
}
input:valid {
background-color: #bbf7d0;
transition-duration: 200ms;
}输入框变为无效时,目标规则提供的时长是 1s;变为有效时,使用的则是 200ms。
逗号列表会按位置配对。属性数量多于时长时,时长列表从头循环;时长数量更多时,超出的值不会参与匹配:
.box {
transition-property: color, border-color, opacity, transform;
transition-duration: 100ms, 240ms;
}所以四个属性依次得到 100ms、240ms、100ms、240ms。节奏函数和延迟列表也遵循同样的循环规则。
transition-timing-function 安排节奏节奏函数不改变总时长,它决定的是这段时间里的进度怎样分配。几个常用关键字及其等价曲线如下:

三次贝塞尔曲线。 cubic-bezier(x1, y1, x2, y2) 通过两个控制点确定曲线。x1、x2 必须落在 0 到 1 之间,因为时间轴不能倒退;y1、y2 可以超出这个范围,因此能够做出先越过终点、再回落的“回弹”感。例如:
.badge {
transition: transform 360ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}阶跃函数。 steps(n, position) 不生成连续曲线,而是把进度分成等距台阶。逐帧精灵图、机械计数器或其他离散进度更适合使用这种函数。
jump-start(也可写 start):开始时立即跳一次。jump-end(也可写 end):最后一次跳变发生在终点。jump-both:起点和终点都跳,视觉上增加一个台阶区间。jump-none:两端都不跳,视觉上减少一个台阶区间。step-start 等于 steps(1, jump-start);step-end 等于 steps(1, jump-end)。以 steps(5, jump-end) 为例,它会先保留初值,再在 20%、40%、60%、80%、100% 处依次跳变。换成 steps(5, jump-start) 后,一开始就会进入第一个新台阶。
下面这个实验把平滑曲线和阶跃函数放在了一起。为了让差异更明显,示例故意拉长了时长;用到真实界面时,需要把速度调回适合操作的范围。
transition-delay 控制起始时间transition-delay 控制样式变化发生后,隔多久才开始插值。0s 表示立即开始;正值会先等待;负值也会立即开始,不过会跳过开头的一段进度。
.item {
transition-property: color, border-radius, width, opacity;
transition-duration: 200ms;
transition-delay: 0s, 200ms;
}这里的四个属性依次匹配 0s、200ms、0s、200ms,所以奇数位置立即开始,偶数位置晚 200ms。利用同一规则,我们可以让多项变化依次接力;也可以让每项的 duration + delay 相等,使它们同时结束。

负延迟容易被误解成“提前运行”,其实它仍然要等状态变化发生才会触发。假设时长是 200ms、延迟是 -150ms,浏览器会在触发时直接从曲线 75% 的位置开始,再用剩余 50ms 到达终点。如果负延迟的绝对值大于或等于时长,变化会立刻完成,也不会产生 transitionend。
.chip {
transform: translateX(0);
transition: transform 200ms linear -150ms;
}
.chip:hover {
transform: translateX(200px);
}在线性曲线里,鼠标移入时元素看起来会先跳到 150px,随后从 150px 移到 200px。移出时同样会应用负延迟:元素先进入反向过程的中段,再走完余下距离。
transition 简写中的时间顺序transition 简写通常按“属性、时长、节奏、延迟”的顺序书写。除了两个时间值,其他部分一般可以调整位置。浏览器遇到的第一个时间始终按 duration 解析,第二个时间才是 delay。
.submenu {
transition:
transform 200ms ease-in 50ms,
opacity 160ms linear 50ms;
}属性省略时默认为 all,节奏省略时默认为 ease。如果只写一个时间,它就是时长,延迟则为 0s。想看到渐变,至少要提供一个非零时长。
若要让值等待一段时间后瞬间切换,可以写 transition: 0s 200ms。这种效果通常会让用户在点击后短暂得不到反馈,随后界面又突然变化,使用前要确认它确实符合交互意图。none 不能和其他项目混在逗号分隔的简写列表里,否则整条简写都会无效。
/* color 没写时长,所以会立即变化;opacity 才会渐变 */
.notice {
transition: color, opacity 200ms ease-in 50ms;
}用户不会特意等动画结束再继续操作。鼠标可能刚进入菜单就移开,按钮也可能被连续点击。遇到这种情况,浏览器会把当前已经显示的中间值当作新起点,直接计算下一段变化,不会强迫上一段先跑到终点。
假设正向过渡走到 40% 时被反转,回程只剩完整路程的 40%。浏览器会相应缩短返回过程,避免用完整时长走完这段短距离。线性节奏下很容易看出这种变化;非线性贝塞尔曲线会按已完成的进度调整反向时长,负延迟也按比例缩短,正延迟则通常在两个方向都完整保留。
阶跃函数还多一层细节。假设 width 在 10s 内通过 steps(10, jump-start) 从 100px 变到 200px,并在 3.25s 时退出。此时元素已经走完 3 个台阶,宽度约为 130px。反向过程大约用 3s 返回,却仍然会切成原来的 10 个台阶,所以每个台阶持续得更短,单次变化也更小。
.meter {
width: 100px;
transition: width 10s steps(10, jump-start);
}
.meter:hover {
width: 200px;
}正向过程没有完成,就不会触发该方向的 transitionend。如果反向过程顺利回到初值,则会在反向完成时触发事件。进入和退出确实需要不同节奏时,可以分别在默认状态与目标状态中写参数,但别忘了测试快速往返的情况。
祖先和后代如果同时过渡一个可继承属性,结果可能不太直观。后代自己的过渡先结束后,仍可能继续继承父级尚在变化的值,于是外观又动了一次。处理这类组件时,最好让同一种视觉变化只归一个层级负责,或者让父子分别过渡不同属性。
当 CSS 动画正在控制某个属性时,过渡无法同时接管这个属性;元素上没有被动画占用的其他属性仍可以正常过渡。排查问题时,可以把 transitionrun、transitionstart、transitioncancel 和 transitionend 一起记录下来。相比只盯着最终画面,事件顺序更容易说明过程在哪里断了。
下面的实验会把事件逐条记下来。你可以快速连续点击,看看取消与反向完成分别会产生什么记录;再勾选“只看 transform”,就能直观看到为什么监听时需要按属性过滤。
判断一个值能不能平滑过渡,可以先问:它的中间值是什么?rotate(10deg) 和 rotate(20deg) 的中点是 15deg,浏览器可以计算;display: block 与 display: inline-block 之间没有连续的数值路径,通常只能直接切换。

auto数值型计算值通常都能动画,比如长度、百分比、角度、颜色、透明度和变换函数。关键字只要最终能够计算成数字,也可以参与插值。例如 font-weight: bold 会转换为数值,border-width: thin 也会计算成长度。
简写属性里可能只有一部分能够动画。border: 3px solid red 变为 10px dashed blue 时,宽度和颜色会渐变,solid 到 dashed 只能直接切换。visibility 也是离散值:从 hidden 变为 visible 时通常一开始就显示,反向变化时则会保持可见,直到过程结束才隐藏。
遇到 auto 时,最好先按不可动画处理。不同浏览器对 height: 0 到 height: auto 的处理不够可靠,可以改用明确上限、网格轨道、变换或其他能够计算中间值的方案:
.details {
max-height: 0;
overflow: hidden;
transition: max-height 240ms ease-out;
}
.details.is-open {
max-height: 60rem;
}这段替代写法也有局限:内容实际高度与 max-height 上限相差越大,视觉速度的偏差就越明显。如果时间和尺寸需要精确对应,应测量内容尺寸,或者换成布局更稳定的方案。min-height: auto 的计算值通常是零,它和常见的 height: auto 不是同一种情况。
普通数字按浮点数插值,必须为整数的值则按整数步进。长度和百分比会先转换为可计算形式;calc() 里彼此兼容的长度、百分比也可以逐项插值。
颜色会先进入相应的颜色空间,再进行插值。如果希望色相变化更容易预期,起点和终点最好使用同一种颜色表示方式,或者明确指定颜色空间。
复合值会拆开处理各个组成部分:
text-shadow 的颜色按颜色插值,横向偏移、纵向偏移和模糊半径按长度插值。box-shadow 的扩散半径可插值,但 inset 与外阴影之间没有连续中间值。translateX(...) rotate(...),避免浏览器进行更复杂的矩阵转换。.tile {
box-shadow: 0 2px 6px rgb(15 23 42 / 0.18);
transform: translateX(0) rotate(0deg);
transition: box-shadow 200ms, transform 200ms;
}
.tile:hover {
box-shadow: 0 16
多背景、多个阴影这类列表值会按位置配对。如果属性允许列表重复,较短的一边会循环展开到所需长度。比如元素有 12 张背景图,background-size 只写了 4 组尺寸,这 4 组会重复 3 次;悬停状态如果只写 3 组,就会重复 4 次。展开后,第 1 项和第 1 项配对,第 2 项和第 2 项配对,依次插值。
.pattern {
background-image: url(a.png), url(b.png), url(c.png), url(d.png);
background-size: 10px 10px, 20px 20px;
transition: background-size 1s ease-in;
}
.pattern:hover {
background-size:
这个例子里的四层背景实际匹配为 10→25、20→50、10→25、20→50。如果其中一对不能插值,比如 background-size 从 contain 变为 cover,这一对会离散切换。浏览器对混合列表的容错可能存在差异,代码不应依赖这些差异行为。
阴影列表长度不一致时,浏览器可以补出透明、零偏移、零模糊的隐式阴影,让对应项逐渐出现或消失。但配对仍有边界:内阴影只能配内阴影,外阴影也只能配外阴影。
打印介质没有可以播放的时间轴。浏览器会重新计算打印样式,结果可能来自当前值、默认非交互值,也可能来自类名决定的最终值;不同实现和触发方式未必一致。因此,打印样式里应明确关掉过渡,并直接给出适合纸张或 PDF 的稳定状态:
@media print {
*, *::before, *::after {
transition: none !important;
}
.details {
max-height: none;
overflow: visible;
}
}只有实际参与插值的值才会产生过渡事件。不存在或不能动画的属性不会破坏列表中的其他项目,浏览器仍会保留它们的位置,避免后续的时长、节奏和延迟错位。CSS 动画正在控制某个属性时,过渡不会接管同一属性,其他属性则仍可各自运行。
把语法写对只是第一步。放进真实界面前,还要逐项检查它是否符合交互需要:
transform 和 opacity。它们通常不改变文档流;频繁过渡 width、height、padding 可能持续触发布局计算。:focus-visible 或 :focus-within。.drawer {
transition: transform 220ms ease-out, opacity 160ms linear;
}
@media (prefers-reduced-motion: reduce) {
.drawer {
transition-duration: 0.01ms;
transition-delay: 0s;
}
}极短的非零时长有时是为了兼容依赖结束事件的旧代码。如果逻辑不依赖事件,直接写 transition: none 会更清楚。业务流程也应有超时或状态兜底,不要把关键步骤全部绑在 transitionend 上。
transition-property。 默认时长是 0s,所以看不到渐变。all。 后续新增属性可能意外加入过渡。height: auto。 改用可计算尺寸、网格、变换或脚本测量。transitionend。 简写属性会拆成长属性分别触发。transitioncancel 与反转。duration。写完一段 CSS 过渡后,我们可以沿着一条清楚的路径检查:先确认哪个状态发生了变化,再用 transition-property 选出参与的属性,用 duration 确定时长,用 timing function 分配进度,用 delay 调整开始时间。接着快速往返操作几次,看看中断与反向是否自然,并核对事件数量、插值方式和列表配对。最后别漏掉打印样式与减少动态设置。
过渡真正解决的是状态变化该怎样被看见。把时长控制在合适范围,让运动目的明确,允许用户关闭它,也别让它承担唯一的信息传递职责。做到这些,过渡就能安静地帮助用户理解界面,而不会抢走操作本身的注意力。