CSS Houdini自定义属性@property给动效带来的扩展

发布于 大漠

Web中制作动效姿势有很多种,比如说纯CSS制作动效,JavaScript制作动效等。但话又说回来,如果你使用CSS来制作动效的时候会有不少的限制,虽然说CSS自定义属性的到来给CSS制作动效带来不少的变化,但还是有一定的缺陷在这里面。不过,CSS Houdini的到来,不说其自身的Animation API,就说他的自定义属性的出现,就可以帮助我们弥补CSS开发动效带来的缺陷和限制。换句话说,CSS Houdini的自定义属性 @property 可以扩展CSS的动效,也可以帮助我们提高CSS开发动效的能力。这篇文章,主要就是和大家一起来探讨,CSS Houdini的自定义属性是如何扩展动效开发的能力。

回顾CSS中的自定义属性

在开始正题之前,先花一点点时间和大家回顾一下CSS自定义属性相关的知识,这对于没有接触过CSS自定义属性以及CSS Houdini的自定义属性的同学会有所帮助。

CSS自定义属性已有多年的发展史了,到今天为止也得到所有主流浏览器的支持,并且在一些项目中得到运用。CSS自定义属性的注册和引用非常的简单:

/* 使用 -- 前缀注册了一个全局的CSS自定义属性 */
:root {
    --color: #09f;
}

/* 使用 var()函数引用了已注册的自定义属性 --color */
a {
    color: var(--color)
}

/* 在链接悬浮状态重新注册(修改了)自定义属性 --color的值 */
a:hover {
    --color: #f36;
}

CSS自定义属性的出现能够帮助我们更好的管理复杂样式中的值,并且提高代码的可读性,可维护性。

要是你从未接触过CSS自定义属性相关的知识,那么你真的有必要花一些时间阅读下面这几篇文章,这样有助于你更好的了解CSS自定义属性,并且很好的在项目中使用它,如此一来可以体会到CSS自定义属性给开发者带来的好处:

而且CSS自定义属性的出现,可以帮助我们更好的制作Web动效,比如下面这个示例:

阅读上面示例代码,你会发现,借助CSS自定义属性特性,并且结合setProperty这个API可以让我们非常容易的实现上面示例中展示的动画效果。但这并不代表CSS自定义属性实现Web动效就是万能的。但这并不是万能的,因为我们在 @keyframes 中使用CSS自定义属性就无法稳定地实现动画效果。比如:

.box {
    width: 50px;
    height: 50px;
    --offset: 0;
    border-radius: 5px;
    background-color: #09f;

    transform: translateX(var(--offset));
    animation: moveBox 2s linear infinite;
}

@keyframes moveBox {
    0% {
        --offset: 0;
    }
    50% {
        --offset: 300px;
    }
    100% {
        --offset: 600px;
    }
}

虽然蓝色盒子在移动,但并没有过渡的效果:

运行示例会发现动画效果非常的生硬:

出现这种效果是不是不可思议的,其实在CSS自定义属性的规范中针对这种现象做过相应的描述

Notably, they can even be transitioned or animated,since the UA has no way to interpret their contents, they always use the “flips at 50%” behavior that is used for any other pair of values that can’t be intelligently interpolated.

大致意思是说:“ 尤其是,它们(自定义属性)甚至可以被过渡或动画化,但因为用户代理无法解释这些内容,它们永远采用为所有它们不能智能补值的值所采用的‘抛硬币’行为。

不过CSS Houdini的 @property 出现之后,可以帮助我们达成这一目标,它允许 我们明确的定义这些CSS自定义属性,例如为其提供值类型,初始值,以及定义这些值是否应该继承 @property。简单地说,我们使用@property注册的自定义属性,可以轻易帮助我们让上面示例动效变得顺滑流畅:

@property --offset {
    syntax: "<length-percentage>";
    inherits: true;
    initial-value: 0px;
}

.box {
    --offset: 0;
    transform: translateX(var(--offset));
    animation: moveBox 2s linear infinite;
}

@keyframes moveBox {
    0% {
        --offset: 0;
    }
    50% {
        --offset: 300px;
    }
    100% {
        --offset: 600px;
    }
}

运行上面的示例,看到的动画效果要原生CSS自定义属性实现的效果要流畅:

正如上面示例所示,@property注册CSS自定义属性很简单:

@property --property-name {
    syntax: '<length-percentage>';
    initial-value: '0px';
    inherits: false;
}

另外,除了使用@property注册CSS自定义属性之外,还可以在JavaScript中使用CSS.registerProperty注册自定义属性:

CSS.registerProperty({
    name: '--property-name',
    syntax: '<length-percentage>',
    inherits: false,
    initialValue: 0
})

CSS Houdini的自定义属性和原生CSS自定义属性相比,最大的差异就是多了 值类型检测初始值设置是否可继承。也正因为,有了值类型的检测等特性,我们就可以使用 @property 注册的自定义属性结合一些“技巧”来支持使用自定义属性的动画。

那么我们可以用它来做哪些有趣的事呢?下面我们通过一些示例来展示@property对动画制作带来的变化。在开始以下内容之前要是未接触过CSS Houdini的CSS自定义属性,可以先阅读下面文章:

让颜色动起来

除了在《CSS 颜色》一文之外,我在多处场合提到过在CSS中设置颜色时hsl()hsla()这样的函数非常的灵活,比如说,我们在相同的饱和度(S)和亮度(L)只改变色相(H)就可以得到不同的颜色:

有了CSS自定义属性之后,改变色相得到不同的颜色变得更容易,比如下面这个示例:

:root {
    --hue: 10
}

span {
    color: hsl(var(--hue) 80% 50%);
    text-shadow: 1px 1px 0 hsl(calc(var(--hue) / 2) 80% 50%),
        2px 2px 0 hsl(calc(var(--hue) / 3) 80% 50%),
        3px 3px 0 hsl(calc(var(--hue) / 4) 80% 50%),
        5px 5px 0 hsl(calc(var(--hue) / 5) 80% 50%),
        6px 6px 0 hsl(calc(var(--hue) / 6) 80% 50%),
        7px 7px 0 hsl(calc(var(--hue) / 7) 80% 50%),
        8px 8px 0 hsl(calc(var(--hue) / 8) 80% 50%),
        9px 9px 0 hsl(calc(var(--hue) / 9) 80% 50%),
        10px 10px 0 hsl(calc(var(--hue) / 10) 80% 50%);
}

span:nth-child(2) {
    --hue: 20;
}

span:nth-child(3) {
    --hue: 40;
}

span:nth-child(4) {
    --hue: 60;
}

span:nth-child(5) {
    --hue: 80;
}

span:nth-child(6) {
    --hue: 100;
}

span:nth-child(7) {
    --hue: 120;
}

span:nth-child(8) {
    --hue: 140;
}

span:nth-child(9) {
    --hue: 160;
}

span:nth-child(10) {
    --hue: 180;
}

span:nth-child(11) {
    --hue: 200;
}

span:nth-child(12) {
    --hue: 220;
}

span:nth-child(13) {
    --hue: 240;
}

span:nth-child(14) {
    --hue: 260;
}

span:nth-child(15) {
    --hue: 280;
}

span:nth-child(16) {
    --hue: 300;
}

span:nth-child(17) {
    --hue: 320;
}

span:nth-child(18) {
    --hue: 340;
}

span:nth-child(19) {
    --hue: 360;
}

span:nth-child(20) {
    --hue: 380;
}

在这个示例中,CSS自定义属性--hue被设置到每一个span(也就是每个字母上),并且每个span--hue20的基数往上叠加(每个字母的色相值都不同)。

可以给每个字终添加动画效果,并且每个为每个字母设置一个负的延迟动画,比如 --index的基础上减.2s;另外同时运用同一个@keyframes动画来循环变换它们的颜色:

:root {
    --hue: 10
}

span {
    color: hsl(var(--hue) 80% 50%);
    text-shadow: 1px 1px 0 hsl(calc(var(--hue) / 2) 80% 50%),
        2px 2px 0 hsl(calc(var(--hue) / 3) 80% 50%),
        3px 3px 0 hsl(calc(var(--hue) / 4) 80% 50%),
        5px 5px 0 hsl(calc(var(--hue) / 5) 80% 50%),
        6px 6px 0 hsl(calc(var(--hue) / 6) 80% 50%),
        7px 7px 0 hsl(calc(var(--hue) / 7) 80% 50%),
        8px 8px 0 hsl(calc(var(--hue) / 8) 80% 50%),
        9px 9px 0 hsl(calc(var(--hue) / 9) 80% 50%),
        10px 10px 0 hsl(calc(var(--hue) / 10) 80% 50%);
    animation: rainbow 2s linear calc(var(--index, 0) * -0.2s) infinite;
}

@keyframes rainbow {
    0%,
    100% {
        --hue: 10;
    }
    5% {
        --hue: 20;
    }

    10% {
        --hue: 40;
    }

    15% {
        --hue: 60;
    }

    20% {
        --hue: 80;
    }

    25% {
        --hue: 100;
    }

    30% {
        --hue: 120;
    }

    35% {
        --hue: 140;
    }

    40% {
        --hue: 160;
    }

    45% {
        --hue: 180;
    }

    50% {
        --hue: 200;
    }

    55% {
        --hue: 220;
    }

    60% {
        --hue: 240;
    }

    65% {
        --hue: 260;
    }

    70% {
        --hue: 280;
    }

    75% {
        --hue: 300;
    }

    80% {
        --hue: 320;
    }

    85% {
        --hue: 340;
    }

    90% {
        --hue: 360;
    }

    95% {
        --hue: 380;
    }
}

另个使用JavaScript给每个span--index设置不同的值:

const letterEles = document.querySelectorAll(".container > span");

for (var i = 0, len = letterEles.length; i < len; i++) {
    letterEles[i].style.setProperty("--index", i);
}

我们尝试着用@property来定义--hue

@property --hue {
    initial-value: 0;
    inherits: false;
    syntax: '<number>'
}

使用@property注册自定义属性将会告诉浏览器,--hue将会是一个数字类型(syntax:<number>指定了),而不是一个看起来像数字的字符串(注意,原生CSS注册的自定义属性会被理解成字符串)。

众所周知,HSL颜色空间,色相H的值是从0360(也可以是0-360,旋转方向不同,当然也可以是大于360,当大于360时,相当于该值与360的差值,比如390等同于390 - 360 = 30,即30)。而且@property告诉浏览器,自定义属性--hue是一个不会被继承的值,那么我们可以将动画简化成:

@keyframes rainbow {
    to {
        --hue: 360
    }
}

两者效果是不是非常相似:

但使用@property会让代码量变得少很多。当然,这个示例可能没有文章开头那个示例那么形像,但这里想表述的是,有了@property能力,我们在动画中可以:

  • 对颜色饱和度动画化
  • 对颜色的亮度动画化
  • 在动画中使用不同的缓动函数
  • 等等

还有很多你可以想像的。另外就是我们可以在不同的元素中有作用域名地共同使用这个动画化的值。比如下面这个示例,按钮的边框(border)和阴影(box-shadow)会在鼠标悬浮的时候流转一整个色轮的颜色:

鼠标在按钮上移动,你将看到下图这样的效果:

上面示例使用的关键代码:

@property --hue {
    syntax: '<integer>';
    inherits: true;
    initial-value: 0;
}

:root {
    --bg: #1a1a1a;
    --button-bg: #000;
}

button {
    --border: hsl(var(--hue, 0), 0%, 50%);
    --shadow: hsl(var(--hue, 0), 0%, 80%);
    
    background: var(--button-bg);
    border-color: var(--border);
    box-shadow: 0 1rem 2rem -1.5rem var(--shadow);
    transition: transform 0.2s, box-shadow 0.2s;
}

button:hover {
    --border: hsl(var(--hue, 0), 80%, 50%);
    --shadow: hsl(var(--hue, 0), 80%, 50%);
    animation: hueJump 0.75s infinite linear;
    transform: rotateY(10deg) rotateX(10deg);
}

button:active {
    transform: rotateY(10deg) rotateX(10deg) translate3d(0, 0, -15px);
    box-shadow: 0 0rem 0rem 0rem var(--shadow);
    animation-play-state: paused;
}

@keyframes hueJump {
    to {
        --hue: 360;
    }
}

回到刚才文字的示例,我们可以把阴影和鼠标的移动结合起来,将会是另一个效果:

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

:root {
    --clientX: 10;
    --clientY: 10;
}

@keyframes rainbow {
    to {
        --hue: 360;
    }
}

span {
    color: hsl(var(--hue) 80% 50%);
    text-shadow: calc(var(--clientX) * 0.1px) calc(var(--clientY) * 0.1px) 0
        hsl(calc(var(--hue) / 2) 80% 50%),
        calc(var(--clientX) * 0.2px) calc(var(--clientY) * 0.2px) 0
        hsl(calc(var(--hue) / 3) 80% 50%),
        calc(var(--clientX) * 0.3px) calc(var(--clientY) * 0.3px) 0
        hsl(calc(var(--hue) / 4) 80% 50%),
        calc(var(--clientX) * 0.4px) calc(var(--clientY) * 0.4px) 0
        hsl(calc(var(--hue) / 5) 80% 50%),
        calc(var(--clientX) * 0.5px) calc(var(--clientY) * 0.5px) 0
        hsl(calc(var(--hue) / 6) 80% 50%),
        calc(var(--clientX) * 0.6px) calc(var(--clientY) * 0.6px) 0
        hsl(calc(var(--hue) / 7) 80% 50%),
        calc(var(--clientX) * 0.7px) calc(var(--clientY) * 0.7px) 0
        hsl(calc(var(--hue) / 8) 80% 50%),
        calc(var(--clientX) * 0.8px) calc(var(--clientY) * 0.8px) 0
        hsl(calc(var(--hue) / 9) 80% 50%),
        calc(var(--clientX) * 0.9px) calc(var(--clientY) * 0.9px) 0
        hsl(calc(var(--hue) / 10) 80% 50%);
    animation: rainbow 2s linear calc(var(--index, 0) * -0.2s) infinite;
}

import gsap from "https://cdn.skypack.dev/gsap";

const letterEles = document.querySelectorAll(".container > span");
const container = document.querySelector(".container");

for (var i = 0, len = letterEles.length; i < len; i++) {
    letterEles[i].style.setProperty("--index", i);
}

document.addEventListener("pointermove", ({ x, y }) => {
    gsap.set(container, {
        "--clientX": gsap.utils.mapRange(0, window.innerWidth, -100, 100, x),
        "--clientY": gsap.utils.mapRange(0, window.innerHeight, -100, 100, y)
    });
});

这个示例中,我们将text-shadowxy坐标的值分别使用了--clientX--clientY,用户在屏幕中移动鼠标时会动态改变它们的值,从而改变文本阴影的效果:

注意,示例中的自定义属性,都可以使用@property来注册,就像示例中的--hue那样。另外示例中还用到了GASP动画库,如果你没有用过,可以阅读下面的文章:

动态计数

是否还记得《CSS Houdini: @property 注册自定义属性》介绍@property的时候,在文章中我们一起聊了一个有趣的动画效果,那就是使用@property来实现动态计数的效果。

其中的主要技巧就是利用@property注册自定义属性时,可以指定其值类型为<integer>(或<number>),然后借助CSS的计数属性counter-resetcounter-incrementcounter()

比如下面这个示例。我们使用counter()以及伪元素的content将数字转换成字符串,并使用这些字符串作为伪元素content的值:

@property --milliseconds {
    inherits: false;
    initial-value: 0;
    syntax: '<integer>';
}

.counter {
    counter-reset: ms var(--milliseconds);
    animation: count 100s steps(100) infinite;
}

.counter:after {
    content: counter(ms);
}

@keyframes count {
    to {
        --milliseconds: 100;
    }
}

也就是说,如果你不想要非常精准的秒表动效(一般秒表记数是使用JavaScript来实现,比如通过selInterval实现),那它和上面示例中的计数器效果是等同的。@Jhey 在Codepen上就用这个原理实现了一个纯CSS的秒表记时器

动画化的渐变

在介绍CSS渐变的时候提到过,在CSS中我们要是想给渐变添加动效,或者过渡效果,只能通过改变background-position的值来模拟,如果想过渡或者动画化渐变中的颜色断点,是需要依靠CSS自定义属性来实现。其中原委在《使用CSS transition和animation改变渐变状态》一文中有介绍过。

不过@property可以让我们轻易的给渐变的颜色断点添加动效。比如下面这个效果:

主要将@property定义的颜色色相,conic-gradient()渐变的角度值:

/* 定义圆角渐变的角度值 */
@property --angle {
    initial-value: 0deg;
    inherits: false;
    syntax: '<number>';
}

/* 定义渐变颜色中的色相值 */
@property --hue {
    initial-value: 0;
    inherits: false;
    syntax: '<angle>';
}

@keyframes中改变--angle--hue的值,并且动画在每次迭代的时候会先暂停一下,然后更新这两个自定义属性的值:

@keyframes load {
    0%, 10% {
        --angle: 0deg;
        --hue: 0;
    }
    100% {
        --angle: 360deg;
        --hue: 100;
    }
}

并且使用border-image来运用conic-gradient()创建的渐变图像,即给按钮添加了一个带有动效的图片(渐变)边框:

.loader {
    --charge: hsl(var(--hue), 80%, 50%);
    border-image: conic-gradient(var(--charge) var(--angle), transparent calc(var(--angle) * 0.5deg)) 30;
    animation: load 2s infinite ease-in-out;
}

这就上面示例中的关键性代码。

另外 @una用CSS Houdini自定义属性写的一个渐变边框效果,并且文章中也详细的阐述了@property动画化渐变边框的原理:

@JoshWComeau在他的教程《Magical Rainbow Gradients》中也用CSS Houdini自定义属性和React Hooks结合起来构建一个神奇的彩虹渐变组件MagicRainbowButton

让变换(transform)变得更酷

众多开发者都知道,使用纯CSS开发动效的时候,很多情况之下都离不开CSS的transform特性,不管是2D的Transform还是3D的Transform。但在使用它们在制作动效时,有的时候会显得非常的生硬(看起来不像它应有的效果)。比如下面这个抛物线效果,即从A点移到B点,并同时模仿带有重力的效果:

@keyframes throw {
    0% {
        transform: translate(-500%, 0);
    }
    50% {
        transform: translate(0, -250%);
    }
    100% {
        transform: translate(500%, 0);
    }
}

以前,我们主要是靠在动画元素上套个容器,然后分别在xy两个方向分别动画化实现这个效果:

详细的介绍可以阅读《Moving along a curved path in CSS with layered animation》一文。

有了@property之后,我们可以同时动画化变换中每个独立的:

@property --x {
    inherits: false;
    initial-value: 0%;
    syntax: '<percentage>';
}

@property --y {
    inherits: false;
    initial-value: 0%;
    syntax: '<percentage>';
}

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

.ball {
    animation: throw 1s infinite alternate ease-in-out;
    transform: translateX(var(--x)) translateY(var(--y)) rotate(var(--rotate));
}

接着在@keyframes不同的帧中调整已注册的--x--rotate的值:

@keyframes throw {
    0% {
        --x: -500%;
        --rotate: 0deg;
    }
    50% {
        --y: -250%;
    }
    100% {
        --x: 500%;
        --rotate: 360deg;
    }
}

再来看一个@Ana Tudor提供的示例,使用同样的技术来实现Meterial设计中的Ripple按钮效果

还可以复杂一点,就是使用该技术模仿CSS的motion-path属性实现路径动效,比如@Jhey写的小汽车沿着一个四环公路行驶的动效

制作饼图和圆形进度条

在《图解CSS: CSS渐变》一文中介绍了conic-gradient()repeating-conic-gradient()两个属性绘制饼图效果:

在这个基础上,把CSS Houdini的自定义属性运用到conic-gradient()就可以创建带有动效的饼图。@Ana Tudor在2018年发表的《Simple Interactive Pie Chart with CSS Variables and Houdini Magic》一文中详细介绍了下面这个效果的实现过程:

如果把CSS的mask特性和CSS Houdini自定义属性结合在一起,可以很容易地实现像下面这个Demo的圆环进度条效果:

其他效果

前两天看到 iCSS前端趣闻 (微信公众号)转发了 @alphardex 的两篇文章:

文章收集了很多CSS写的动画效果。如果你感兴趣的话,可以尝试着使用@property特性,让这些动效变得更简单,更完美。除此之外,@Ana Tudor在Codepen提供了很多更复杂的案例

在感兴趣的话,还可以结合CSS Houdini的 Paint API一起来构建更有意思的效果,比如Houdini.how提供的示例(有很多示效是你平时写起来比较痛苦的,有些是你意想不到的):

Houdini.how是一个Web网站,它由@Una Kravets创建,也可以称作为CSS Houdini的库(Library)。它提供了CSS Houdini的Worklets、资源和相关参考资料。

我尝试着使用它们写了一个小示例:

小结

@property 的基础特性很简单,但他最为出色的是,我们可以使用它的能力实现很多复杂,具有创意的动效。甚至结合CSS Houdini的其他能力达成一些以前使用基本字符串所不能实现的奇思妙想。我希望这篇使用 @property 构建的不同动效案例能够激发你学习然后制作你自己的超赞例子的兴趣!非常期待能在评论中看到你使用@property构建的各种有创意的案例。