动画,就是几何对象在若干状态之间的切换,每次切换花费时间不小于 0,切换过程肉眼可见。

科研民工的工作成果主要是论文,数据可视化主要靠静态图片。所以我对动画的了解约等于零,这次来补课。ChatGPT 推荐学 Motion 库,在此之前最好了解一下基本的 CSS 动画。

动画,就是几何对象在若干状态之间的切换,每次切换花费时间不小于 0,切换过程肉眼可见。

transform

所谓“若干状态”中,很重要的一类状态是通过对 HTML 元素默认的形状和位置进行变换得到的。

transform 不一定用在动画中,它可以表示 HTML 元素的任意仿射变换,甚至用矩阵来表示变换的方式都来自于数学

不用矩阵,也可以用独立的函数来表示平移、缩放、旋转、扭转,代码可读性更好。

#anime {
    transform: matrix(1, 2, 3, 4, 5, 6);

    transform: translate(120px, 50%);
    transform: scale(2, 0.5);
    transform: rotate(0.5turn);
    transform: skew(30deg, 20deg);
    transform: scale(0.5) translate(-100%, -100%);
}

CSS 动画大体可分为两类:

  • transition:HTML 元素在不同 pseudo-class 之间(鼠标悬浮、点击)切换时不同属性的渐变效果,一般需要用户输入来触发
  • animation + @keyframes:用时间轴和关键帧描述的动画,不需要外界触发

transition

此类动画的不同状态由选择器和/或各种伪类指定:

#anime { 
    margin-right: none;
    height: 1.5em;
}

#anime:hover  { margin-right: 2em; }

#anime:active { height: 2em; }

在 CSS 的选择器里指定 transition 相关的属性,决定切换过程的细节:

#anime {
    transition-delay:    0s;                       /*动画触发后等待的时间*/
    transition-duration: 0s;                       /*动画持续的时间*/
    transition-property: all | margin-right | ...; /*发生变动的属性*/
    transition-timing-function: ease;              /*变速函数*/
    transition-behavior: normal | allow-discrete;  /*二选一*/
}

以上多个命令可以合并成一行 transition,中间用空格分开

#anime {
    transition:  all      4s     ease-in-out       1s   allow-discrete;
           /* property  delay  timing-function  duration  behavior */
}

Easing 缓动函数

transition-timing-function 指定了 transition 非匀速变化的具体形式。

ChatGPT 推荐的下面两个网站,直观展示了各种取值的实际效果:

  1. https://easings.net/?utm_source=chatgpt.com
  2. https://cubic-bezier.com/?utm_source=chatgpt.com

animation + @keyframes

@keyframes 定义关键帧

此类动画的时间轴和关键帧由 @keyframes 定义。

动画的名字放在 @keyframes 之后,花括号之前。

花括号内以不同百分比表示每个关键帧在时间轴上的位置,百分比后的花括号内指定关键帧中元素的不同属性的取值。

@keyframes myanime {
  0% {
    top: 0;
  }
  50% {
    top: 30px;
    transform: rotate(0.5turn);
  }
  75% {
    top: 10px;
    transform: rotate(0.75turn);
  }
  100% {
    top: 0;
    transform: rotate(1.0turn);
  }
}

如果只有 0 和 100% 两个时间点,还可以写成 fromto 关键字,更有可读性。

@keyframes my-anime {
  from {
    top: 0;
  }
  to {
    top: 10px;
    transform: rotate(0.5turn);
  }
}

animation 使用动画

当某个 CSS 选择器里有 animation-name 属性时,被选择的 HTML 元素就具有动画名指向的动画效果。animation-* 的其他属性决定动画总时长等等属性,和上文 transition 的各属性类似。

#anime {
	top: 5px;
	animation-name: my-anime;
	animation-delay: 5s;
	animation-timing-function: ease-in-out;
	animation-duration: 1s;
	animation-behavior: allow-discrete;
}

以上多个命令可以合并成一行 animation,中间用空格分开:

#anime {
    animation:  all      4s     ease-in-out       1s   allow-discrete;
            /* name  delay  timing-function  duration  behavior */
}

例子

TailwindCSS 中的自定义动画

语法和普通 CSS 不同。在自己项目的 global.css 文件中:

@theme {
  --animate-wiggle: wiggle 1s ease-in-out infinite;
  @keyframes wiggle {
    0%,
    100% {
      transform: rotate(-3deg);
    }
    50% {
      transform: rotate(3deg);
    }
  }
}

这样就可以在 HTML 中用 <div class="animate-wiggle"> 使用动画。

Headless UI 的 <Transition/>

有显示和不显示两种状态,由自身的 open 变量的布尔值确定。两个状态之间有进场和退场两个过程。结合 TailwindCSS:

  • 普通的 TailwindCSS 类所指定的样式应用于显示状态
  • 不显示的样式由 data-closed: 前缀指定
  • 进场过程中的变化由 data-enter: 前缀指定
  • 退场过程中的变化由 data-leave: 前缀指定

官方给的例子

import { Transition } from '@headlessui/react'
import clsx from 'clsx'
import { useState } from 'react'

function Example() {
  const [open, setOpen] = useState(false)

  return (
    <div className="relative">
      <button onClick={() => setOpen((open) => !open)}>Toggle</button>
      <Transition show={open}>
        <div
          className={clsx([
            // Base styles
            'absolute w-48 border transition ease-in-out',
            // Shared closed styles
            'data-closed:opacity-0',
            // Entering styles
            'data-enter:duration-100 data-enter:data-closed:-translate-x-full',
            // Leaving styles
            'data-leave:duration-300 data-leave:data-closed:translate-x-full',
          ])}
        >
          I will enter from the left and leave to the right
        </div>
      </Transition>
    </div>
  )
}

一个宽度 48×4px 的 <div/>,在网页加载后隐藏,透明度为 0;

用户点击 Toggle 按钮之后,<div/> 从左向右平移出现,用时 100 毫秒;

再次点击 Toggle 按钮之后,<div/> 从右向左平移消失,用时 300 毫秒;

shadcn.ui 中的 <Accordion/>

上个版本的博客用的是 shadcn.ui 组件库,每篇文章的标题卡片用的是 <Accordion/> 组件,点击右侧的箭头之后,可以展开和收起文章的摘要。

后来换成 HeadlessUI 之后,展开和收起的动画效果就消失了。

现在来看一下源码(老版本的 shadcn 还在用 Radix-ui,现在这个库已经转投 base-ui 了):

const Accordion = AccordionPrimitive.Root

const AccordionItem = React.forwardRef(function AccordionItem({ className, ...props }, ref) {
  return (
    <AccordionPrimitive.Item
      ref={ref}
      className={cn("border-b", className)}
      {...props}
    />
  )
})
AccordionItem.displayName = "AccordionItem"

const AccordionTrigger = React.forwardRef(function AccordionTrigger({ className, children, ...props }, ref) {
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        ref={ref}
        className={cn(
          "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
          className
        )}
        {...props}
      >
        {children}
        <ChevronDown className={cn("h-4 w-4 shrink-0 transition-transform duration-200")} />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
})
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName

const AccordionContent = React.forwardRef(function AccordionContent({ className, children, ...props }, ref) {
  return (
    <AccordionPrimitive.Content
      ref={ref}
      className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
      {...props}
    >
      <div className={cn("pb-4 pt-0", className)}>{children}</div>
    </AccordionPrimitive.Content>
  )
})
AccordionContent.displayName = AccordionPrimitive.Content.displayName

可以看到里面用了 animate-accordion-upanimate-accordion-down 两个动画,它们定义在项目的 tailwind.config.js 中:

module.exports = {
    theme: {
        extend: {
            animation: {
                "accordion-down": "accordion-down 0.2s ease-out",
                "accordion-up": "accordion-up 0.2s ease-out",                
            },
            keyframes: {
                "accordion-down": {
                    from: { height: "0" },
                    to: { height: "var(--radix-accordion-content-height)" },
                },
                "accordion-up": {
                    from: { height: "var(--radix-accordion-content-height)" },
                    to: { height: "0" },
                },
            },
        }
    }
}

这里的技术关键是 var(--radix-accordion-content-height) 是针对每个 accordion 实例计算的,是怎么做到的?Radix 的魔法?

这超出我的能力范围了, 以上面一整个小节为 prompt 问了 ChatGPT,回答放在了《ChatGPT | Radix Accordion 开合的动画效果

本文收录于以下合集: