过渡

本章概要

在传统的打印媒介上,事物都是静止不动的。文字不能在页面上自由挪动,色彩也无法任意变换。但 Web 是个活灵活现的全新媒介,可以实现更多效果。人们可以在页面中添加动效、引入各种变化,而实现它们最简单的方式,就是 过渡(transitions

有了 CSS 过渡效果,您就可以让浏览器在某个值发生改变时,将其从原来的某个值平缓过渡(ease)到另一个值。例如某个悬停状态下为红色的蓝色链接,如果使用了过渡特效,当用户划过该元素时,链接就会从蓝色先过渡到紫色、再由紫色过渡到红色;而当鼠标移走时再恢复如初。如果正确使用,过渡可以进一步增强页面的交互效果,并且由于我们的眼睛更容易被动态的事物所吸引,所以当变化产生时可以更好地获得用户关注。

通常,给页面添加过渡效果是不怎么费劲的。本章会介绍如何实现 CSS 过渡以及其间需要考虑的相关问题。有些案例可能会比较复杂,因此我们也会不同的情况进行考察。

15.1 状态间的由此及彼

From here to there

过渡特效是通过一系列形如 transition-* 的样式属性来实现的。当元素的某个属性值发生变化时,设置在元素上的过渡特效就会生效,实现该属性样式的平稳过渡,而非立即切换到新的属性值。

下面利用按钮来创建一个基本示例,以演示 CSS 过渡的基本工作原理。本例从一个直角的蓝绿色按钮开始,鼠标悬停时,该按钮将过渡为一个圆角的红色按钮。图 15.1 展示了这两种状态以及其间过渡时的状态。

图 15.1 过渡前、过渡中以及过渡后的元素效果
图 15.1 过渡前、过渡中以及过渡后的元素效果

在新页面中添加一个按钮,并关联到一个新的样式表。按钮的 HTML 标记如代码清单 15.1 所示。

代码清单 15.1 为页面添加一个简单的按钮

<button type="button">Hover over me</button>

接着根据代码清单 15.2 同步更新样式表。这些样式定义了按钮的正常状态与悬停时的状态,其中有两个过渡属性,用于指示浏览器在两种状态间实现流畅过渡。

代码清单 15.2 具有过渡效果的按钮样式示例代码

button {
  padding: 0.3em 1em;
  border: 0;
  font-size: 1rem;
  color: white;
  background-color: oklch(74% 0.11 195deg); /* 蓝绿色按钮 */
  transition-property: all; /* 让过渡对所有属性变化生效 */
  transition-duration: 0.5s; /* 过渡时间为 0.5s */
}

button:hover { /* 悬停时按钮为红色、带圆角边框 */
  border-radius: 1em;
  background-color: oklch(55% 0.16 24deg);
}

属性 transition-property 用于指定对哪些属性启用过渡效果。本例中的关键字 all 表示过渡将对所有属性的变化生效。而属性 transition-duration 则表示过渡到最终状态时经历的时长,本例中即为 0.5s,表示 0.5 秒。

加载页面并使用鼠标划过按钮,就能看到该过渡效果。注意,尽管我们并没有在正常状态下明确声明圆角半径为 0border-radius 属性仍旧十分流畅地从 0 过渡到了 1em。因为按钮自动将圆角半径的初始值设为了 0,圆角过渡便由 0 开始。当鼠标从元素上移开时,过渡效果则会反向生效。您也可以在悬停状态中试着改改其他样式属性,例如 font-size 或者 border 等等。

元素属性任何时候发生变化都会触发过渡:可以是状态改变的时候,比如 :hover;也可以是 JavaScript 导致状态变化的时候,例如添加或删除某个影响元素样式的 class 类。

注意,我们并没有在 :hover 规则集中设置形如 transition-* 的过渡属性,而是将它们设置在了一个可以始终选中该元素的选择器上,尽管我们确实是想让过渡效果对鼠标悬停状态生效。我们希望能在进入悬停状态时(淡入过渡)和退出悬停状态后(淡出过渡)都能看到过渡效果;而当其他属性改变时,我们往往并想让过渡属性本身也发生改变。

提示
在过渡效果结束时,您也可以使用 JavaScripttransitionend 事件来执行某类操作。

此外,还可以使用过渡特效的简写属性 transition,其语法结构如图 15.2 所示。该简写属性最多可接受四个参数值,分别代表这四个过渡属性:transition-propertytransition-durationtransition-timing-function 以及 transition-delay

图 15.2 简写属性 transition 的语法结构

【图 15.2 简写属性 transition 的语法结构】

第一个参数值用于确定哪些属性需要启用过渡,其初始值为关键字 all,表示对所有属性均生效;但若只有一个属性需要过渡,在这里指定属性名称即可。例如,transition-property: color 将只对元素颜色启用过渡,其余属性变化时则立即完成。也可以设置多个值,例如 transition-property: color, font-size

第二个参数值为持续时间,是一个用秒(如 0.3s)或毫秒(如 300ms)表示的时间值。

警告
与长度值不同,0 不是一个合法的时间值。您必须为时间值指定一个单位(如 0s 或者 0ms),否则声明将无效,并被浏览器忽略。

第三个参数值为定时函数,用于控制属性中间值的计算方式,从而有效控制整个过渡过程中变化率如何加速或减速。定时函数的值可以是一个关键字,例如 linear 或者 ease-in,也可以是一个自定义函数。这是 CSS 过渡特效的核心知识,稍后将详细介绍。

最后一个参数为延迟时间,用于指定在属性值改变之后、过渡特效生效前的这段等待时间。如果为按钮的悬停状态切换设置 0.5s 的过渡延迟,那么当鼠标指针进入元素 0.5s 之后才会触发过渡效果。

如果需要为两个不同的属性分别设置不同的过渡效果,可以添加多个过渡规则,并以逗号进行分隔,如以下代码所示:

transition: border-radius 0.3s linear, background-color 0.6s ease;

相应地,如果使用普通写法,上述代码等效于以下样式声明:

transition-property: border-radius, background-color;
transition-duration: 0.3s, 0.6s;
transition-timing-function: linear, ease;

本章稍后还将演示一个使用了多个过渡规则的案例。

无障碍浏览设置:减少动效 Accessibility: Reduced motion

仔细考虑页面上的动效、尤其是在定义了大量动效的情况下,思考它们究竟会对用户带来怎样的影响就显得至关重要了。某些用户可能患有前庭运动障碍(vestibular motion disorders);这通常是在人的内耳中出现的问题,当屏幕上出现某些动态效果时,患者会出现不同程度的不适感。操作系统为此类用户提供了相关配置,以减少屏幕动效对其造成的不良影响。

您应当启用媒体查询 prefers-reduced-motion 来查询该配置,并酌情调整 CSS 样式。在第 8 章,我曾建议将下列规则集引入 reset 图层样式中。添加该样式后,页面上所有的过渡与动画效果都会被移除,这样就无需手动重复相关操作了:

@layer reset {
  @media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.01ms !important;
      scroll-behavior: auto !important;
    }
  }
}

上述代码用到了 !important 标记,以确保这些过渡与动画效果在任何情况下都能被彻底移除。注意,这里并没有声明 transition: none,而是设置了一个很短的持续时长,以达到动效不被察觉的目的。这样一来,页面中基于过渡或动画实现的逻辑(如 JavaScript 中的 animationend 事件)就仍然可以正常工作。

15.2 定时函数

Timing functions

定时函数是 CSS 过渡的重要组成部分。过渡特效实现了让某个属性从一个值 “移动”(“move”)到另一个值;而定时函数则用于描述该过程究竟是 “怎样”(“how”) 移动的:是以恒定的速度移动?还是先缓慢起步,再逐渐加速?

我们可以使用几个关键字来描述该移动过程,例如 linearease-inease-out 等。若按 linear 过渡,则属性值将以恒定的速度变化;若改为 ease-in,则变化速度开始时慢,但在过渡结束前会加快;相反,ease-out 则会减速过渡,开始时快速变化,结束时却放慢了速度。图 15.3 演示了小方块在这几种定时函数的作用下,由左侧移动到右侧时的不同过渡效果。

图 15.3 linear 过渡以恒定速度移动;而 ease-in 则加速;ease-out 则减速
图 15.3 linear 过渡以恒定速度移动;而 ease-in 则加速;ease-out 则减速

仅从静态图片上理解这几个过程可能有点困难,为此,让我们通过一个示例来说明,以便在浏览器中实时查看不同的过渡效果。新建一个 HTML 页面并根据代码清单 15.3 添加如下 HTML 代码:

代码清单 15.3 一个简单的定时函数示例 HTML

<div class="container">
  <div class="box"></div><!-- 该小方块将从屏幕左侧过渡到右侧-->
</div>

接着,给小方块设置好颜色和尺寸大小;然后使用绝对定位,并在鼠标悬停时利用过渡特效来移动小方块的位置。最后给页面添加新样式表,将代码清单 15.4 中的样式复制到本地样式表内。

代码清单 15.4 使小方块从左侧过渡到右侧的样式代码

.container {
  position: relative;
  height: 50px;
}

.box {
  position: absolute;
  left: 0; /* 从左侧开始 */
  height: 50px;
  width: 50px;
  background-color: oklch(70% 0.18 145deg);
  transition: all 1s linear; /* 设置过渡 */
}

.container:hover .box {
  left: 400px; /* 鼠标悬停时右移 400px */
}

这样,在示例页的左上角将会得到一个绿色的小方块。当鼠标悬停到方块所在的容器上时,方块将向右侧移动。注意,此时方块将以恒定的速度做匀速运动。

警告

本例通过给元素设置绝对定位并对其 left 属性使用过渡特效,实现了元素在屏幕上匀速移动的效果。然而有一些属性因为性能原因要尽量避免使用过渡特效,其中就包括 left 属性。后续章节还会提到这些问题,届时将利用变换(transforms)来作为更好的替代方案。

现在可以试着改改过渡属性,看看不同的定时函数是怎么工作的。例如尝试 ease-in(即 transition: all 1s ease-in)或者 ease-out(即 transition: all 1s ease-out)。仅用这些关键字就能实现相应的效果了,但有时您可能希望对定时函数实现更精准的控制。这时就需要使用自己定义的定时函数。下面来看看如何实现。

15.2.1 定制贝塞尔曲线

Custom Bézier curves

定时函数是以数学定义下的贝塞尔曲线(Bézier curves)为基础的。浏览器将贝塞尔曲线作为某属性值随时间变化的函数曲线,并根据该函数算出某时刻的属性值。图 15.4 展示了几种定时函数的贝塞尔曲线以及对应的定时函数关键字。

图 15.4 定时函数的贝赛尔曲线描述了数值随时间的变化情况

【图 15.4 定时函数的贝赛尔曲线描述了数值随时间的变化情况】

这些贝塞尔曲线都是从左下方开始一直延伸到右上方。时间是从左向右推进的,而曲线则描述了该数值从开始到抵达终点前的数值变化过程。线性定时函数 linear 的变化率在整个过渡过程中一直保持稳定,呈现为一条直线。其余定时函数为曲线,代表加速和减速变化。

然而,我们不应该局限在这五种关键字上。我们也可以定义自己的三次贝塞尔曲线(cubic Bézier curve),实现更温和(gentle)或更强烈(drastic)的过渡效果。甚至可以添加一些“回弹”(“bounce”)效果。下面就来一试身手。

在刚才创建的页面中,打开开发者工具 DevTools 并检查绿色小方块元素。在 Chrome 浏览器的 “样式(Styles)” 面板或者 Firefox 浏览器的 “规则(Rules)” 面板中,您将看到定时函数旁边有一个小小的标志符,单击该标志符会打开一个弹窗。您可以在该弹窗中修改定时函数曲线(如图 15.5 所示)。

图 15.5 在 Chrome 浏览器的开发者工具中编辑贝塞尔曲线
图 15.5 在 Chrome 浏览器的开发者工具中编辑贝塞尔曲线

在弹窗左侧的操作界面上,提供了一系列预定义的贝塞尔曲线。尽管 Firefox 提供的内置曲线比 Chrome 浏览器更多,但 Chrome 浏览器支持通过底部的左右小箭头来循环预览其他选项。您也可以点击其中某条曲线进行选择。而在操作界面的右侧则会放大展示选中贝塞尔曲线的具体情况。

曲线的每个末端都有一条短直线(即 控制柄(handles);直线上附带的小圆点,称为 控制点(control points。点击并拖动小圆点则可以改变曲线形状,注意观察控制柄的长度和方向是如何 “牵引”(“pulls”) 曲线的。

点击弹窗外部区域就可以关闭该弹窗,此时定时函数已然更新,之前类似 ease-out 的关键字不见了,取而代之的是像 cubic-bezier(0.45, 0.45, 0.55, 0.95) 这样的值。cubic-bezier() 函数和其中的四个参数共同组成了我们自定义的定时函数。

关于定时函数的选取 Selecting a timing function

无论是使用关键字形式的定时函数还是自定义贝塞尔曲线版本,了解什么时候该使用哪种函数都是很有必要的。每个网站或应用程序都应该包含一条减速曲线(decelerating curve)、一条加速曲线(accelerating curve)以及 linear 关键字对应的函数曲线。最佳实践是复用相同的几条曲线,提供更加一致的用户体验。

可以在下列场景中分别使用这三种函数:

当然,这些都不是硬性规定,只是提供了一些基本思路,如果感觉不太合适,完全可以不用遵守这些规则。有时,您可能还需要更多的曲线来实现更复杂或者更有趣的过渡效果,例如使用 ease-in-out(先加速后减速)或者回弹特效(演示案例详见第 16 章)。

下面再来深入考察一下 cubic-bezier() 函数的工作原理。图 15.6 给出了另一个曲线示例。这是一条自定义的贝塞尔曲线。该曲线在开始时加速,速度在中间位置达到最大值(即曲线上最陡峭的部分),而后开始减速。曲线位于笛卡尔网格(译注:即直角坐标系)内,其起点坐标为 (0, 0),终点坐标则为 (1, 1)

图 15.6 用贝塞尔曲线描述的某定时函数

【图 15.6 用贝塞尔曲线描述的某定时函数】

由于曲线的两个端点是固定的,因此只需要再确定两条控制柄的位置就可以定义出该曲线了。在 CSS 中,该曲线可以通过 cubic-bezier(0.45, 0.05, 0.55, 0.95) 来定义,其中的四个参数分别代表了两个控制柄(handles)上的控制点的 x 坐标和 y 坐标。

我们很难通过这些数字想象出曲线的具体形态。相比之下,利用图形界面工具来编辑贝塞尔曲线要简单、直观得多,因此我喜欢先在浏览器中编辑并测试过渡效果,然后再将生成的三次贝塞尔曲线复制到样式表中。我比较喜欢使用 DevTools 工具来完成这类任务;您也可以使用一些像 https://cubic-bezier.com 这样的在线工具。

15.2.2 阶跃

Steps

最后再介绍一种定时函数,即 steps() 函数。跟前面介绍的基于贝塞尔曲线将某个值流畅过渡到下一个值的过渡特效不同,steps() 函数是以一系列离散的、瞬时 “阶跃”(“steps”)来移动的。

阶跃函数需要两个参数:阶跃次数和一个用来表示每次变化发生在阶跃的开始还是结束的关键词(start 或者 end)。图 15.7 描述了部分阶跃函数。

图 15.7 step() 函数以阶跃递增的方式改变数值
图 15.7 step () 函数以阶跃递增的方式改变数值

注意,因为第二个参数值默认为 end,所以可以使用 steps(3) 来代替 steps(3, end)。想要查看阶跃函数的过渡效果,试根据代码清单 15.5 同步更新本地样式表。

代码清单 15.5 利用 steps() 函数来增加数值的示例样式代码

.box {
  position: absolute;
  left: 0;
  height: 50px;
  width: 50px;
  background-color: oklch(70% 0.18 145deg);
  transition: all 1s steps(3); /* 设置三次非连续的阶跃过渡效果 */
}

此时就不是一秒内(即过渡持续时间)流畅地从左到右移动了,时间被分成了三等分,或者说三步。每走一步,小方块就先后分别出现在起始位置、三分之一位置、三分之二位置,以及最后 1s 时移动到终点位置。

注意

默认情况下,属性值在每一步结束时发生改变,因此过渡效果不会立即开始。添加 start 关键字变为 start(3, start) 后就可以改变该默认行为。这样,过渡就会发生在每步开始时,而非结束时。

阶跃函数 steps() 的实际应用并不多见,不过 CSS-TRICKS 网站的这篇《Clever Uses for Step Easing》博文列举了大量有关该函数的创意案例,或许可以为您提供一些灵感。

15.3 非动画属性

Non-animatable properties

大部分过渡设置都很容易理解。比如,对链接元素设置 transition: color 200ms linear 后,当鼠标悬停或点击这些链接,它们就会由一种颜色过渡到另一种颜色。您也可以对某个可点击板块(clickable tile)的背景色,或者某按钮的内边距添加过渡特效。

但要是 JavaScript 修改了页面上的某些内容,可能就得考虑考虑添加过渡是否合适了。有些情况比较简单,就和为元素添加过渡属性差不多;但如果遇到的是一些复杂情况,可能就需要考虑一些额外配置了。本章后续内容将构建一个带过渡特效的下拉菜单,让它在展开时较为柔和,而不是冷不丁地突然冒出来。

我们希望先给菜单设置淡入(fade in)特效,即对 opacity 属性添加过渡;接着再修改下拉菜单,并对 height 属性添加另一个过渡效果。这两步操作都存在一些问题,需要我们再思考一下。

下拉菜单的页面效果如图 15.8 所示。我们先来实现菜单的展开和关闭,然后再添加过渡效果。菜单下方专门放了一个链接,需要在菜单的下拉抽屉展开时渲染到链接元素的上方,这一点非常重要。

图 15.8 下拉菜单关闭(左侧)与打开(右侧)时的页面效果图
图 15.8 下拉菜单关闭(左侧)与打开(右侧)时的页面效果图

为下拉菜单新建一个页面,添加代码清单 15.6 中的示例代码。这与前面章节构建的下拉菜单差不多,也包含了一些 JavaScript 脚本来触发菜单的开启与关闭。

代码清单 15.6 带过渡特效的下拉菜单 HTML 标记

<div class="dropdown" aria-haspopup="true">
  <button class="dropdown__toggle" type="button">Menu</button>
  <div class="dropdown__drawer"><!-- 抽屉元素会显示与隐藏,对应下拉菜单的功能 -->
    <ul class="menu" role="menu">
      <li role="menuitem">
        <a href="/features">Features</a>
      </li>
      <li role="menuitem">
        <a href="/pricing">Pricing</a>
      </li>
      <li role="menuitem">
        <a href="/support">Support</a>
      </li>
      <li role="menuitem">
        <a href="/about">About</a>
      </li>
    </ul>
  </div>
</div>
<p><a href="/read-more">Read more</a></p><!-- 一个位于下拉菜单下方的链接元素 -->

<script type="module">
  var toggle = document.getElementsByClassName('dropdown__toggle')[0];
  var dropdown = toggle.parentElement;
  // 点击按钮时在容器上切换样式类 is-open 的添加与删除
  toggle.addEventListener('click', function () {
    dropdown.classList.toggle('is-open');
  });
</script>

添加淡入特效前的样式代码如代码清单 15.7 所示。将它们更新到本地样式表,并将样式表关联到示例页面。样式中以及包含了一些过渡效果,在鼠标悬停时颜色可以平滑过渡。此外没有太多新内容,页面已经基本搭好了,接下来就可以专注于淡入特效的实现了。

代码清单 15.7 带过渡效果的下拉菜单样式代码

@layer global, modules;

@layer global {
  body {
    font-family: Helvetica, Arial, sans-serif;
  }
}

@layer modules {
  .dropdown {
    --border-color: oklch(61% 0.08 314deg);
    --text-color: oklch(39% 0.06 314deg);
    --text-color-focused: oklch(39% 0.2 314deg);
    --background-color: white;
    --highlight-color: oklch(95% 0.01 314deg);
  }
  .dropdown__toggle {
    display: block;
    padding: 0.5em 1em;
    border: 1px solid var(--border-color);
    color: var(--text-color);
    background-color: var(--background-color);
    font: inherit;
    text-decoration: none;
    transition: background-color 0.2s linear; /* 给背景色添加过渡效果 */
  }
  .dropdown__toggle:hover {
    background-color: var(--highlight-color); /* 鼠标悬停时改变背景色 */
  }
  .dropdown__drawer {
    position: absolute;
    display: none;
    background-color: var(--background-color);
    width: 10em;
  }
  .dropdown.is-open .dropdown__drawer {
    display: block;
  }

  .menu {
    padding-left: 0;
    margin: 0;
    list-style: none;
  }
  .menu > li + li > a {
    border-top: 0;
  }
  .menu > li > a {
    display: block;
    padding: 0.5em 1em;
    color: var(--text-color);
    background-color: var(--background-color);
    text-decoration: none;
    transition: all 0.2s linear; /* 给背景色和文字颜色设置过渡特效 */
    border: 1px solid var(--border-color);
  }
  .menu > li > a:hover { /* 鼠标悬停时改变颜色 */
    background-color: var(--highlight-color);
    color: var(--text-color-focused);
  }
}

在浏览器中打开页面并查看实际效果,点击 “Menu” 按钮应该就能打开或关闭下拉菜单。注意观察按钮和菜单链接在鼠标悬停时是如何平滑过渡颜色样式的,鼠标移开后又是如何变化的。

这里对悬停效果使用了 0.2s 的过渡持续时间。根据经验,绝大部分的过渡持续时间应该介于 200ms500ms 之间。如果持续时间过长,用户就会感觉页面反应迟钝,页面响应让他们产生了无谓的等待。尤其是面对那些经常出现或反复使用的过渡特效时,情况更是如此。

提示

对于鼠标悬停、淡入淡出以及轻微缩放特效,应该使用较快的过渡速度。一般要控制在 300ms 以内,有时甚至还会低至 100ms;而对于那些特效中包含大幅移动或者过程复杂的定时函数的情况,例如回弹特效(bounces,详见第 16 章),持续时间则可以适当拉长,控制在 300ms500ms 之间即可。

我在处理过渡特效时,有时会放慢到两三秒。这样就能仔细观察整个过渡过程,确保浏览器是按我想要的效果在运行。如果您也考虑这样做,记得在完成调试后改回合适的速度。

15.3.1 不可添加动画效果的属性

Properties that cannot be animated

并非所有属性都可以添加动画效果,例如 display 属性就是其中之一。您可以在 display: nonedisplay: block 之间切换,但无法在这两个属性值之间设置过渡。因此任何作用于 display 属性上的过渡特效都会被忽略。

如果您在 MDN(https://developer.mozilla.org/)之类的参考指南上查阅属性,它们就会明确告诉您某个属性是否可以添加动画效果、以及哪些类型的值(如长度、颜色、百分比)支持插值运算。MDN 文档中关于 background-color 属性的详细说明(详见:https://mng.bz/1Gdj)如图 15.9 所示。

图 15.9 MDN 文档为每个属性都提供了一份技术能力摘要信息表
图 15.9 MDN 文档为每个属性都提供了一份技术能力摘要信息表

如图所示,background-color 属性只有一个颜色值时才能添加动画,即从一种颜色变到另一种颜色(这也不难理解,因为属性必须设置为单一的颜色值)。该属性的动画类型(Animation Type)不仅对过渡设置有效,也对第 17 章即将介绍的动画设置有效。此外,文档中还列出了该属性的其他实用信息,比如它的初始值、可以作用于哪些元素、以及该样式属性能否继承等等。如果您需要一份有关某个属性用法的完整技术摘要,可以在 MDN 文档中找到该属性并查看其 形式定义信息表(Formal Definition table)

说明
大部分接受长度值、数值、颜色值、或者 calc() 函数值的样式属性都支持动画效果的设置;而大部分使用关键字或其他离散值的属性(如 url())则不支持动画设置。

如果您查阅过 display 属性,就会发现它的动画类型为 discrete。这就意味着它只能被赋予非连续的属性值,无法在动画或过渡特效中做插值计算。如果要实现元素的淡入淡出效果,就不能对 display 属性添加过渡特效了,但可以使用 opacity 属性。

注意
display 属性规范最近有调整,因此目前可以像 visibility 那样支持过渡设置。为此,需要添加声明 transition-behavior: allow-discrete。目前该属性尚未获得浏览器的全面支持,但您可以参考谷歌开发者博客专栏上的这篇博文了解更多详情:Four new CSS features for smooth entry and exit animations

根据作者提示,我在本地也测了一下 display 的过渡特效,示例代码如下:

<body>
    <style>
        #box {
            display: block;
            width: 100px;
            height: 100px;
            background: red;
            transition: display 1s linear allow-discrete;
        }
    </style>
    <div id="box"></div>
    <button id="btn">Toggle display</button>
    <p id="info">Press to Start</p>
    <script>
        const box = document.querySelector('#box');
        const btn = document.querySelector('#btn');
        const info = document.querySelector('#info');
        btn.addEventListener('click', e => {
            const invisible = box.style.display === 'none';
            box.style.display = invisible ? 'block' : 'none';
            info.innerText = `display: ${box.style.display}`;
        });
    </script>
</body>

加载页面后,点击按钮并不会让红色方块立即消失,而是在过渡结束时(即 1 秒时)突然消失(而当再次点击按钮,红色方块会立即渲染):

补图2:属性值 allow-discrete 的实测效果对比图

【补图 2:属性值 allow-discrete 的实测效果对比图】

最后再补充说明一下 MDN 上关于 display 属性的最新形式定义表声明:

补图3:MDN 在线文档关于 display 属性的形式定义信息表

【补图 3:MDN 在线文档关于 display 属性的形式定义信息表】

请注意最后一行,display 的动画类型为 Discrete,但后面还有一句补充说明:离散行为,但如果动画过渡以 none 开始或至 none 结束,则其在整个持续时间内都是可见的。由于动画特效将在第 17 章介绍,这里暂不展开谈论了。相关演示案例到第 17 章时我再补充说明。

15.3.2 淡入与淡出

Fading in and out

下面,我们将利用元素不透明度的过渡设置,为下拉菜单的打开和关闭分别添加淡入与淡出特效。最终效果如图 15.10 所示。

图 15.10 实现后的菜单淡入特效示意图
图 15.10 实现后的菜单淡入特效示意图

opacity 的属性值可以是介于 0(完全透明)到 1(完全不透明)之间的任意值。代码清单 15.8 展示了上述效果的基本思路。但仅有这些样式还不够,很快您就会明白原因。先对照示例代码同步更新本地样式表。

代码清单 15.8 设置不透明度与过渡特效的示例样式代码

.dropdown__drawer {
  position: absolute;
  background-color: var(--background-color);
  width: 10em;
  opacity: 0; /* 用于替换 display: none */
  transition: opacity 0.2s linear; /* 给不透明度设置过渡特效 */
}
.dropdown.is-open .dropdown__drawer {
  opacity: 1; /* 用于替换 display: block */
}

此时,虽然打开和关闭菜单的淡入淡出效果已经实现了,但问题是下拉菜单关闭后并没有消失 —— 只是完全透明了,而且仍在页面上!如果这时点击 “Read more” 字样的链接,该链接并不会正常工作;我们点击的其实是链接前面的透明菜单项,直接跳到了 “Features” 页面。

我们确实需要利用不透明度来设置过渡,但同时也希望在下拉菜单变为不可见时彻底移除该元素。而这可以借助另一个属性 visibility 来实现。

visibility 属性可以从页面上移除某个元素,用法与 display 属性类似,也可以赋值为 visiblehidden;但与 display 的区别在于,visibility 支持动画设置(animatable)。虽然对 visibility 设置过渡不会令其逐渐消失,但 transition-delay 仍然有效;而 display 属性则不会生效。

说明
为某个元素设置 visibility: hidden 可以从可见页面中移除该元素,但不会从文档流中移除它,这就意味着该元素仍然占据着页面空间。其他元素会继续围绕该元素的位置布局,在页面上留下一个空白区域。在本例中暂不影响下拉菜单,因为之前对该元素设置了绝对定位。

我们可以活用 visibility 的这一特性来实现想要的动画效果。试根据代码清单 15.9 同步更新本地样式表,然后再来了解它的工作原理。

代码清单 15.9 巧用过渡延迟对 visibility 设置特效的示例样式代码

.dropdown__drawer {
  position: absolute;
  background-color: var(--background-color);
  width: 10em;
  visibility: hidden; /* 菜单关闭时设为隐藏 */
  opacity: 0; /* 菜单关闭时设为透明 */
  transition:
    opacity 0.2s linear,
    visibility 0s linear 0.2s; /* 给 visibility 添加 0.2s 的过渡延迟 */
}
.dropdown.is-open .dropdown__drawer {
  visibility: visible; /* 菜单打开时设为可见 */
  opacity: 1; /* 菜单打开时设为完全不透明 */
  transition-delay: 0s; /* 添加 is-open 类时移除过渡延迟 */
}

上述代码中,我们将过渡设置分为了两组值来定义淡出行为。第一组值为 opacity 设置了 0.2s 的过渡时间;第二组值则为 visibility 设置了 0s 的过渡(立即执行),但有 0.2s 的延迟。换句话说,先执行的是 opacity 的过渡,结束后再紧接着执行 visibility 的过渡。这样就实现了菜单的缓慢淡出,并且过渡到完全透明时可见性立刻切换为 hidden。至此,用户就可以随意点击 “Read more” 链接而不受下拉菜单的干扰了。

菜单淡入的时候,我们需要不同的顺序:此时 visibility 需要立即触发,然后再执行 opacity 的过渡。这就是为什么我们在第二个规则集中把过渡延迟设为 0s 的原因。这样一来,下拉菜单在关闭时其实是隐藏的(hidden),但在整个淡入淡出的过渡过程中都是可见的。

此外,本例中的淡出效果除了使用 CSS 过渡延迟,还可以利用 JavaScript 脚本来实现;但我发现这样要写更多代码,反而更容易出错。不过有时候为了实现想要的效果,JavaScript 脚本是绕不开的(稍后就会看到)。当过渡或动画效果可以仅凭 CSS 来实现时,使用 CSS 过渡与动画将始终是不二之选。

15.4 过渡到自动高度

Transitioning to auto height

本节将尝试为下拉菜单添加另一种常见的页面效果,即通过高度的过渡来滑动打开和关闭下拉菜单。最终效果如图 15.11 所示。

图 15.11 通过过渡高度来滑动打开下拉菜单的效果图
图 15.11 通过过渡高度来滑动打开下拉菜单的效果图

我们希望当下拉菜单打开时,高度将从 0 过渡到正常高度(即 auto);而菜单关闭时又会过渡回 0。代码清单 15.10 展示了该特效的基本思路,只可惜该样式并不管用。请先根据示例代码同步更新本地样式表,然后再来看看问题出在哪、又该如何处理。

代码清单 15.10 对高度设置过渡的示例样式代码

.dropdown__drawer {
  position: absolute;
  background-color: var(--background-color);
  width: 10em;
  height: 0; /* 关闭状态下高度为 0 */
  overflow: hidden; /* 关闭状态下 overflow 设为 hidden */
  transition: height 0.2s ease-out; /* 对高度设置过渡特效 */
}
.dropdown.is-open .dropdown__drawer {
  height: auto; /* 打开状态下的高度由内容决定 */
}

设置 overflowhidden,是为了在关闭或者过渡过程中截断下拉菜单的内容。而这并未生效的原因就在于,一个属性值是无法从长度值 0 过渡到 auto 的。此时下拉菜单仍将正常打开和关闭,只是没有动画效果。

您也可以手动设置一个高度值,比如 120px,但问题是没办法预判高度的具体大小。因为只有当内容在浏览器中渲染完成之后高度才会确定下来,因此只能通过 JavaScript 来获取这个值。

页面加载完毕后,我们访问 DOM 元素的 scrollHeight 属性,就可以拿到这个高度值。然后,就可以把下拉菜单打开时的元素高度修改为新获取到的值。试根据代码清单 15.11 同步修改本地示例页面。

代码清单 15.11 精确设置元素高度,让页面过渡设置生效

<script type="module">
  var toggle = document.getElementsByClassName("dropdown__toggle")[0];
  var dropdown = toggle.parentElement;
  var drawer = document.getElementsByClassName("dropdown__drawer")[0];
  var height = drawer.scrollHeight; // 获取抽屉元素自动高度对应的计算属性值

  toggle.addEventListener("click", function () {
    dropdown.classList.toggle("is-open");
    if (dropdown.classList.contains("is-open")) {
      drawer.style.setProperty("height", height + "px"); // 手动设置高度来打开菜单
    } else {
      drawer.style.setProperty("height", "0"); // 将高度值重置为 0 来关闭菜单
    }
  });
</script>

现在,除了触发 is-open 样式类外,我们还为元素的高度设置了精确的像素值,这样就可以过渡到正确的高度位置。然后在关闭菜单时再把高度重置为 0,就又能过渡回初始状态。

警告

如果某个元素使用 display: none 隐藏起来,那它的 scrollHeight 属性值将为 0。此时,可以先将 display 属性设为 block(即 el.style.display = 'block'),接着获取其 scrollHeight 大小,然后再重置 display 的值(即 el.style.display = 'none')。

有时候过渡特效需要 CSS 与 JavaScript 相互配合。在某些情况下,可能更容易想到的方案是整个逻辑全部通过 JavaScript 来实现。例如,可以只利用 JavaScript 重复设置新的高度值就能实现高度的过渡效果。但通常情况下,我们应该尽可能多地让 CSS “勇挑重担”,去实现那些更耗费性能的页面效果。浏览器对这部分已经做过优化了(因此在性能上的表现会更加优越),并且提供了类似过渡曲线的特性,避免了手动实现需要书写的大量代码。

15.5 自定义属性的过渡设置

Transitioning custom properties

在前面的示例中,我们见过自定义属性值在 CSS 过渡特效中的应用了。例如在代码清单 15.7 中(译注:详见 15.3 节内容),我们就实现了从 background-color: var(--background-color)background-color: var(--highlight-color) 的过渡,该样式对下拉菜单中的切换按钮生效。然而,在某些情况下,我们可能想对自定义属性本身设置过渡,而不是针对它们所修饰的样式属性;例如,希望直接将 --background-color 从红色过渡到蓝色。这类过渡效果默认情况下是不会生效的。

为此,需要给浏览器提供更多信息,具体来说,我们需要声明该属性的 数据类型(data type)。这样浏览器才知道如何在两种颜色、两种长度值、或者其他特定类型之间执行正确的插值计算。在 CSS 中,数据类型的声明是通过 @property 规则实现的。

警告

截至 2024 年年中,Firefox 浏览器尚未提供对 @property 规则的特性支持,但预计很快就支持了。在启用该功能并将其作为页面开发的核心功能前,还请查阅 Can I User 官网获取最新的浏览器兼容情况。

一个 @property 规则必须对自定义属性的以下三个方面进行描述,即:它的数据类型(或语法规则)、该属性是否应当默认从父元素继承,以及该属性的初始值。以下代码为一个典型的 @property 规则定义:

@property --hue {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

上述代码定义了一个自定义属性 --hue。该属性必须为一个角度值(如 15deg);它不会像正常属性那样继承属性值,并且初始值为 0deg。有了这样的定义,浏览器就会将其他类型的属性值解析为无效属性值。例如将某个百分比赋给该属性,写作:--hue: 15%;则浏览器将忽略该声明并将其属性值重置为初始值 0deg。如果不声明具体的值,则默认值也是初始值。

代码清单 15.12 为一个定义好的自定义属性的示例应用。该代码利用自定义属性,实现了按钮悬停时切换背景颜色的过渡特效。通过改变 OKLCH 颜色值中的色相角,按钮会在鼠标悬停时由紫色过渡为蓝色。如果没有 @property 规则,过渡效果将无法生效。

代码清单 15.12 对自定义属性设置过渡的示例样式代码

@property --hue { /* 完成属性 --hue 的定义 */
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

button {
  padding: 0.5em 1em;
  border: none;
  color: white;
  --hue: 314deg;
  background-color: oklch(39% 0.1 var(--hue)); /* 应用该属性 */
  transition: --hue 0.5s linear; /* 对该属性本身设置过渡特效 */
}

button:hover {
  --hue: 250deg;
}

上述代码声明了一个 syntax 值为 <angle> 的数据类型。该 syntax 语法值支持的合法声明如下 ——

此外还可以使用关键字,例如 auto。需要注意的是,给定的语法值必须用双引号括起来。

为自定义属性提供规则定义不仅有利于过渡和动画效果的设置,同时还是实现 类型安全(type safety 的有效手段。这样一来,即便不慎将一个不符合语法定义的无效属性值赋给了该自定义属性,浏览器也可以轻松忽略掉这个值。

您还可以定义出更复杂的语法形式,只是在某些情况下,这么做可能会让浏览器无法基于该属性、对过渡或者动画特效执行更为复杂的插值计算。

使用 | 标记作为语义 “或” 来使用,则可以表示该属性值的数据类型可以是给定的两个或多个类型中的一个。例如 syntax: "true | false" 就定义了一个布尔属性;再比如声明 syntax: "<color> | <hue> | auto",则属性值可以为某颜色值、某色调或者关键字 auto

使用 + 标记则表示用空格分隔的属性值列表。例如:syntax: "<integer>+" 表示接受一个或多个整数型的属性值;同理,# 标记则用于定义一个以逗号分隔的属性值列表。

最后,还可以使用星号 * 来定义一个通用语法值。因此 syntax: "*" 将允许将该属性设置为任意类型的值。这样一来,初始值的定义就不作硬性要求了(not required)。

对于 CSS 过渡来说,只学习这些知识还不够。我们将在下一章把过渡与变换(transforms)结合起来协同开发。

15.6 本章小结 Summary