生态产品多个设计器现已发布候选版本

查看生态总览
跳转到内容

动画层 ​

@xihan-ui/animations 提供现成的动效:11 个进场预设、6 个注意预设,外加错开起播与文字拆分。零第三方依赖,框架无关,任何框架(或无框架)都可直接安装使用。

它建立在 动效原语 之上:减弱动效的降级由该层统一处理,本层不另开通道。减弱动效下两族预设都不播,元素直接呈现终态:它们是表现性的动效,不承载信息。

点下面的名字

进场

注意

动画是配方,不是类名 ​

一段动画是一份可 JSON 序列化的配方(MotionSpec):若干视觉帧加一组时序参数。

ts
import type { MotionSpec } from "@xihan-ui/animations";

const riseUp: MotionSpec = {
  frames: [
    { opacity: 0, y: 24, scale: 0.98 },
    { opacity: 1, y: 0, scale: 1 },
  ],
  duration: 420,
  easing: "emphasized",
};

一帧可以修改六个属性:opacity x y scale rotate blur。位移写数字按 px,写字符串原样透传('100%')。省略的字段表示该帧不参与该属性的插值。

配方是纯数据,因此可以存入数据库、由界面下拉切换、由用户修改后存回。播放器在入口按 validateMotionSpec 校验合上本次选项与时长系数之后的配方,不合法时同步抛错、不起播,不做钳制或补值:

  • 帧数 1–60(MAX_FRAMES),时长与起播延迟 0–60000ms(MAX_DURATION),有限播放次数不超过 1000(MAX_ITERATIONS)。
  • opacity 在 0–1,scale 与 blur 不为负,位移与旋转是有限数,偏移量在 0–1 且按帧序不减。
  • 整段与逐帧缓动必须是命名缓动或合法的 CSS 缓动函数,由 resolveEasing 判定。
  • 闪烁:任意一秒内明暗交替不得超过三次(MAX_FLASHES_PER_SECOND,WCAG 2.3.1)。按不透明度计算,同一方向连续变化满 0.1 算一次起落,一明一暗算一次闪烁;连播时上一遍末帧跳回下一遍首帧也算一次变化。peakFlashesPerSecond(spec) 返回一份配方任意一秒内最多闪几次。

展开为宿主可识别的关键帧时有一条规则:某个属性只要有一帧声明过,其余帧就补上它的中性值。只在中间帧出现的属性,宿主会以元素当前的计算值作为端点,而该值随皮肤变化:同一份配方在不同皮肤下会成为不同的动画。

预设 ​

进场一族把元素从不在场带到静息态:

fade fade-up fade-down fade-start fade-end zoom-in zoom-out blur-in rise drop-in spin-in

注意一族从静息态出发,回到静息态:

shake pulse bounce wobble flash heartbeat

两族都不保留终值:播完后元素回到皮肤定义的外观,动画不接管静息态。这也是它们可以反复播放的原因。

fade-start / fade-end 标了 logical,横向位移在 RTL 下自动取反;fade-up 这类纵向的不受影响。

预设的时长、位移与曲线取自 动效令牌,由 @xihan-ui/motion 的语义常量传过来,与令牌逐条对账:

预设时长位移缓动
fade--xh-motion-duration-enter—--xh-motion-ease-enter
fade-up fade-down fade-start fade-end zoom-in zoom-out blur-in--xh-motion-duration-slide--xh-motion-distance-lg--xh-motion-ease-enter
rise drop-in spin-inslide 加 enter--xh-motion-distance-lg 的 1.5 倍--xh-motion-ease-emphasis
shake pulse bounce wobble flash heartbeat--xh-motion-duration-attention--xh-motion-distance-md 为准各预设自带

缩放、旋转与模糊的幅度是各预设自身的形状,不对应令牌。核心组件不使用注意一族:校验失败用颜色、图标与文字表达,是否抖动由应用决定。

播 ​

ts
import { createMotionPlayer } from "@xihan-ui/animations";

const motion = createMotionPlayer();

await motion.play(card, "fade-up");
motion.play(input, "shake"); // 校验未通过时抖动
motion.play(el, { frames: [{ opacity: 0 }, { opacity: 1 }], duration: 500 }); // 直接传配方

同一元素上再次播放会先撤销上一段:两段同时写同一批属性时,后一段会从被修改的中间态起步。被撤销的 play 以 'cancelled' 结算,不抛异常。

播放器上有开关、时长系数与预设表。时长系数取 (0, 100],越界时 createMotionPlayer 抛错:

ts
const motion = createMotionPlayer({
  enabled: true, // 接入用户偏好,不替最终用户决定
  speed: 1, // 全局时长系数,越大越慢
  presets: myPresets, // 省略时使用内置预设
});

motion.setEnabled(false); // 关闭时撤销正在播放的动画,但 play 照常结算
motion.cancel(el); // 撤销一个
motion.cancel(); // 全部撤销

播放未收录的名字不播放、不抛异常,只产出一条诊断告警(见 诊断通道)。

错开起播 ​

起点

曦寒 UI 动画层

1
2
3
4
5
6
7
8
ts
await motion.playAll(list.children, "rise", { stagger: 40, from: "center" });

from 决定从哪一端铺开:first 从头、last 从尾、center 从中间向两侧。间隔缺省取 --xh-motion-stagger-step(40ms),叠加在给定的基础延迟上。任意一个被打断,整体即视为被打断。

文字拆分 ​

splitText 把一段文字拆为逐字或逐词的行内块,正好是 playAll 需要的一组元素。

ts
import { splitText } from "@xihan-ui/animations";

const { parts, restore } = splitText(title, { by: "char" });
await motion.playAll(parts, "fade-up", { stagger: 30 });
restore();

按码点切分,不会把 emoji 拆成两半;空白原样保留,换行正常。每一段都是 inline-block:行内元素不受 translate 与 scale 影响。

无障碍

原文会写入容器的 aria-label,拆出的每一段标记 aria-hidden:逐字的 span 会让部分读屏逐字朗读。

这意味着容器的角色必须支持命名(标题、按钮、链接等)。套在裸 div 上时读屏可能读不到无障碍名,此时需另行提供可访问文本。使用完毕后调用 restore()。

反向播放 ​

reverseSpec 把一份进场配方翻转为退场配方:帧序反转、偏移量镜像。逐帧缓动会被丢弃:它描述的是本帧到下一帧的过渡,反转之后该区间的归属改变。

ts
import { motionPresets, reverseSpec } from "@xihan-ui/animations";

await motion.play(el, reverseSpec(motionPresets["fade-up"]));

弹窗这类会挂载卸载的进出场不使用它:那需要在卸载前播完动画,应使用 行为原语 的进出场时序,动画本身交给 CSS @keyframes。这里的退场适合元素常驻、只是隐藏的场景。

滚动进入视口后播放 ​

观察不是动画,不收纳在本包中。用 @xihan-ui/core 的 createViewportEntry 观察,在回调中调用 play:

ts
import { createViewportEntry } from "@xihan-ui/core";

createViewportEntry({
  scope,
  target: () => card,
  onEnter: () => void motion.play(card, "rise"),
});

相关 ​

  • 动效原语:缓动、弹簧、减弱动效偏好
  • 行为原语:进出场时序、滚动观察
  • 声音层:同一套配方、预设、播放器的形态,换为听觉

Released under The MIT License