@lovince /运动
与框架无关的运动系统。 CSS 处理轻的界面运动, GSAP 编排时间线和滚动场景,以及 微弱的 提供了 平滑滚动。每个动画都有一个所有者,一个清理和一个 reduced-motion政策。
状态:
0.4.0、实验。 API之前可能会更改1.0.
bun add @lovince/motion
bun add gsap lenis # only for the entries that use them
我们的目标不是让所有东西都有动画效果。它是让一个网站做的动作 具有一致性、可中断性和可访问性。
条目
每个引擎都位于自己的条目后面,因此应用程序只需为其导入的内容付费 一个缺失的可选节点不会破坏其他任何东西。
| 进口 | 需要 | 给了 |
|---|---|---|
@lovince/motion | - - - - - - | createMotion的偏好, revealOnScroll, fadeIn, splitText, focusTarget, whenMotionAllowed,动作配置和令牌 |
@lovince/motion/gsap | gsap | createTimeline, createStagger, createScrollReveal, createParallax, createHorizontalScroll, gsapScrollDriver |
@lovince/motion/scroll | lenis | createSmoothScroll |
@lovince/motion/presets | gsap | heroEntrance, revealText, revealImage, followState, createPageTransition |
@lovince/motion/vue | vue | useMotion, useMotionEffect, v-reveal |
@lovince/motion/primitives | gsap | initMotion 以及每个数据属性原语 |
@lovince/motion/primitives/<name> | gsap (scroll 也 lenis) | 一个原始的: reveal, split, scrub, parallax, magnetic, marquee, liquid, ripple, progress, headroom, transition, scroll |
@lovince/motion/adapters/vue · react · svelte · astro | gsap +框架 | vMotion, useMotion, motion 行动, bindAstro |
@lovince/motion/styles.css | - - - - - - | 令牌+实用程序+预设+基本样式(或 tokens.css, utilities.css, presets.css, primitives.css 他们自己) |
gsap, lenis, vue 和 react 是可选的同伴,所以你的应用程序控制他们
版本,并且在运行时永远不会有第二个副本。
原则
- 克制。 运动解释了变化;它从不为自己而装饰。12-48px旅行。
- 进入时要放松,离开时要放松。 出口运行于
exitRatio(0.65×)的入口。 - 可中断。 吐温类使用
overwrite: 'auto'和quickTo所以一个新的手势永远不会等着旧的手势。 - 只有变换和不透明度,再加上
clip-path用于显示和液体指示。 - 符号,而不是原始数字。 持续时间,轻松度,距离和错开来自
@lovince/tokens. - 简化运动是一级模式,而不是事后的想法:内容立即显示,循环保持不变。
- 每个原语都返回一个清理操作。
令牌
| 集团 | 值 | CSS |
|---|---|---|
| 持续时间 | Xs 0.2·sm 0.4·md 0.7·lg 1.1·xl 1.6 (s) | --motion-duration-{xs…xl} |
| 缓解 | 出 expo.out ·inOut power3.inOut ·在 power2.in ·软 power2.out ·液体(弹簧) | --motion-ease-{out,in-out,in,soft,glide,liquid} |
| 距离 | Sm 12·md 24·lg 48(在16px根处,按间距比例) | --motion-distance-{sm,md,lg} |
| 错开 | 紧固0.04·底座0.06·松动0.1 (s) | --motion-stagger-{tight,base,loose} |
| exitRatio | 0.65 | --motion-exit-ratio |
import { motionTokens, exitDuration } from '@lovince/motion'
gsap.to(el, { y: 0, duration: motionTokens.duration.md, ease: motionTokens.ease.out })
gsap.to(el, { autoAlpha: 0, duration: exitDuration(motionTokens.duration.md), ease: motionTokens.ease.in })
测试让CSS镜像和TypeScript的值保持同步。
模式
Lovince的界面已经以几种可识别的方式移动。 motionPatterns 将它们命名为新的工作匹配:
| 模式 | 使用 | 时机 |
|---|---|---|
state | 颜色,边框,背景变化 | micro,标准ease |
move | 指示器,拇指,药片的更换位置 | morph,强调轻松 |
enter | 弹窗,祝酒,物品到货 | enter,强调轻松 |
exit | 什么离开 | 0.65× enter,放松 |
liquid | 分节拇指,TAB药丸,选择 | overlay,春沉降 |
scene | 卷轴显示,英雄 | scene.md, expo.out |
loop | 遮罩,旋转器,微光 | 线性,当离开屏幕或被询问时暂停 |
原语
标记页面,然后初始化一次:
<script>document.documentElement.classList.add('js')</script>
<link rel="stylesheet" href="@lovince/motion/styles.css" />
<header data-headroom>…</header>
<div data-scroll-progress></div>
<h1 data-split="lines">Big things, gently</h1>
<p data-scrub>Words light up as you scroll past them</p>
<h2 data-kinetic="scramble">Signal acquired</h2>
<div data-reveal="lg" data-delay="0.1">…</div>
<li data-reveal="pop">…</li>
<img data-parallax="0.2" src="…" alt="" />
<a data-magnetic="0.3" href="#">Book now</a>
<button data-ripple>Pick seat</button>
<div data-marquee="40" data-marquee-velocity>
<ul data-marquee-track>…</ul>
<button data-marquee-toggle>Pause</button>
<button data-marquee-nudge="4">Faster</button>
</div>
<div data-liquid role="tablist">
<span data-liquid-indicator></span>
<button data-liquid-item role="tab" aria-selected="true">Day</button>
<button data-liquid-item role="tab" aria-selected="false">Week</button>
</div>
import { initMotion } from '@lovince/motion/primitives'
import { createScroll } from '@lovince/motion/primitives/scroll'
const motion = initMotion({ scroll: createScroll() })
motion.destroy()
| 属性 | 价值 | 做 |
|---|---|---|
data-reveal | sm · md · lg · pop | 随着画面滚动而逐渐淡出; pop 弹簧在 |
data-split | lines · words | 字体加载后分割;被蒙面的线条从自己的边缘升起 |
data-scrub | 调暗不透明度,默认0.18 | 点亮文字,擦洗卷轴 |
data-kinetic | tilt · letters · flip · blur · scramble · wipe · fill · slide · marker | 动态类型;填充和滑动跟随滚动,其余播放一次; <mark> 获得绘制的高亮(--kinetic-marker) |
data-parallax | 速度,默认为0.15 | 飘浮在卷轴上 |
data-magnetic | 强度,默认0.3 | 拉向指针,仅悬停设备 |
data-marquee | Px /s,默认为40,反向为负 | 无缝循环;暂停悬停,聚焦,离开屏幕和打开 data-marquee-toggle (2.2.2 WCAG) |
data-liquid | - - - - - - | 一颗药丸横跨两种物质,挤压、过度并落在活性物质上 |
data-ripple | - - - - - - | 一个环从压点展开 |
data-scroll-progress | - - - - - - | scaleX 跟踪页面进度 |
data-headroom | 偏移量px,默认值420 | 隐藏向下滚动,显示向上滚动或聚焦 |
data-delay | 秒 | 添加到显露或分裂中 |
data-lenis-prevent | - - - - - - | 让内部滚动条本地滚动 |
每个原语也可以单独调用,如 (root?, options?) => Cleanup:
import { liquid } from '@lovince/motion/primitives/liquid'
const stop = liquid('#filters')
液体指示器跟随组件已经报告的内容: aria-selected, aria-pressed, aria-checked, aria-current 或者一个 data-state 的 on, active 或者 checked因此,它可以与Lovince标签、切换组和分段控件一起使用,而无需连接。
FOUC警卫。 与一个 js 课上 <html>,显示和分割目标开始隐藏在CSS中,所以它们不会在脚本运行之前闪烁。没有JavaScript,在减少运动和清理后,它们是可见的。 initMotion 如果缺少类,则添加该类。
页面转换
transition() 遵循一个顺序,所以滚动,触发器和焦点永远不会不一致:
停止滚动→退出→销毁旧的原语→交换→滚动到顶部→init→刷新→开始滚动→进入
import { transition } from '@lovince/motion/primitives/transition'
const flow = transition({ scroll, init: (container) => initMotion({ root: container }).destroy, focus: 'h1' })
await flow.navigate(async () => { document.querySelector('main')!.replaceWith(await fetchPage(url)) })
适配器
<section v-motion="['reveal', 'liquid']">…</section>
const ref = useMotion<HTMLDivElement>({ primitives: ['reveal'] })
return <div ref={ref}>…</div>
<section use:motion={{ primitives: ['reveal'] }}>…</section>
<script>import { bindAstro } from '@lovince/motion/adapters/astro'; bindAstro()</script>
谁拥有什么
| 引擎 | 拥有 |
|---|---|
| CSS | 悬停,焦点和按压反馈,下划线,小过渡,装饰循环,简单的滚动显示 |
| GSAP | 英雄序列,交错和分割排版,滚动链接的场景,固定,水平画廊,页面过渡 |
| 微弱的 | 当两者同时使用时,在GSAP的时钟上平滑滚动,作为每个滚动条的一个服务 |
给每个动画一个所有者。永远不要用CSS和 马上GSAP。
快速启动
import '@lovince/motion/styles.css'
import { createMotion, revealOnScroll } from '@lovince/motion'
import { gsapScrollDriver } from '@lovince/motion/gsap'
import { heroEntrance } from '@lovince/motion/presets'
import { createSmoothScroll } from '@lovince/motion/scroll'
const motion = createMotion({ root: '#app' })
heroEntrance({ label: '.eyebrow', heading: 'h1', body: '.lede', actions: '.ctas', media: '.hero-image' }, { context: motion })
revealOnScroll('[data-reveal]', { context: motion })
const scroll = createSmoothScroll({ driver: gsapScrollDriver() })
// When this view goes away:
motion.destroy() // reverts every tween, trigger, observer and split it owns
scroll.destroy()
该模型
createMotion()是所有者。 它将选择器限定为根,保存每个 效果和清理,和destroy()将它们全部还原,首先是最新的。每个API 需要{ context }. 如果没有上下文,调用将获得自己的上下文并返回 清理。- 当首选项改变时,效果会重新运行。 效果设置并返回
其撤销。如果用户打开或关闭减少运动,而页面是
打开,效果撤消,再次运行
scope.reduced更新。 进程会继续,所以一个完成的入口永远不会重播。 - 结果,而不是暂停。 任何结束的东西都会返回一个解决的承诺
有一次,
'finished'或者'cancelled',从不拒绝。摧毁了 拥有上下文将取消它。没有什么是等待计时器的。 - 一个词汇。 入口
fade,fade-up,fade-down,slide-left,slide-right,scale和clip在CSS中是一样的 显示,fadeIn(Web Animations)和每个GSAP API。 - 令牌决定。 持续时间,曲线和行进距离来自
@lovince/tokens.defaultMotionConfig使用duration.travel和ease.out(power3.out),以及CSS--motion-*属性是别名 的--lovince-*所以CSS和GSAP的运行方式是一样的。
const config: MotionConfig = { duration: 0.72, ease: 'power3.out', stagger: 0.08, respectReducedMotion: true }
减少运动
一个核心政策是:under prefers-reduced-motion: reduce,运动被取代
它的最终状态,不仅仅是缩短。
| API | 在减速运动下 |
|---|---|
revealOnScroll, createScrollReveal | 显示内容;没有什么是隐藏的 |
fadeIn, createTimeline,预设 | 跳到最终状态;承诺解决 finished |
createParallax, createHorizontalScroll | 不创建;静态布局为 |
revealText | 文本永远不会被分割 |
followState | 各州立即适用 |
createSmoothScroll | 微弱的;本机滚动,实时切换 |
样式表有相同的规则,所以只有css的动作也遵循它。集
respectReducedMotion: false 只为了有意义的动作。
渐进增强
内容永远不会被单独的CSS隐藏。显示只隐藏一个元素
设置 data-motion-state="hidden" 从脚本中删除它。如果
JavaScript、GSAP或样式表无法加载,页面只是静态的。
在服务器上没有效果运行,并且每个条目都安全导入。
CSS层
<a class="motion-link" href="/docs">Docs</a>
<section data-motion-reveal="fade-up">…</section>
<h1 class="motion-fade-up" style="--motion-delay: 0.1s">Hello</h1>
revealOnScroll(targets, { effect, once, threshold, rootMargin, stagger }) 使用
IntersectionObserver,没有GSAP。一起进入的元素得到a
--motion-delay 错开。 fadeIn(targets, { effect, distance, duration, stagger }) 运行Web动画入口 fill: backwards,所以没有
之后徘徊。
公用事业: .motion-link, .motion-lift, .motion-press, .motion-fade-up,
.motion-fade-in, .motion-scale-in, .motion-spin, .motion-pulse. 所有的
它们都是可选类,不会触及没有请求它的元素。
GSAP层
const intro = createTimeline((tl, scope) => {
tl.from(scope.select('h1'), { autoAlpha: 0, y: 28 })
.from(scope.select('.lede'), { autoAlpha: 0, y: 18, duration: 0.5 }, '-=0.4')
}, { context: motion })
await intro.finished // 'finished' | 'cancelled'
createTimeline(build, { context, vars, scrollTrigger }):青少年是 在毁灭时恢复。一个scrollTrigger在减速运动下下降。createStagger(targets, { effect, stagger, onScroll })收藏,收藏,收藏 文档顺序。createScrollReveal(targets, { effect, start, repeat }):批次 一起进入。createParallax(targets, { speed, axis }):线性,擦洗,测量一次。 保持speed小。createHorizontalScroll(section, track, { minWidth }):针在minWidth和更广泛;较窄的屏幕保持了简单的布局。选择屏幕外的项目可将其滚动到视图中。splitText(element, { by, mask, onResplit }):单词、字符或行。它 保持内联标记,通过处理任何脚本Intl.Segmenter,阅读全文 文字到屏幕阅读器,并在宽度改变时重新拆分行。revert()恢复原节点。
ScrollTrigger在第一次使用时注册,而不是在导入时注册。
平滑滚动
const scroll = createSmoothScroll({ driver: gsapScrollDriver(), lenis: { lerp: 0.1 } })
scroll.scrollTo('#pricing', { offset: -64 })
scroll.stop() // e.g. while a modal is open
每个卷轴有一个服务。第二个呼叫相同的卷轴
共享实例和最后一个实例 destroy() 把它拆了,所以是组件
每个人都可以安全地要求它。与 gsapScrollDriver()Lenis在GSAP上运行
并保持ScrollTrigger同步:一个帧循环,而不是两个。没有一个
驱动程序,Lenis运行自己的循环。更喜欢原生滚动应用程序,长表单
以及内容丰富的页面。
预设
heroEntrance({ label, heading, body, actions, media }):阅读顺序; 重叠成一个入口。actions孩子蹒跚。revealText(target, { by: 'words' | 'lines' | 'chars', onScroll }):行 揭示从面具后面升起,并在调整大小的地方重建。revealImage(frames, { scale }):画面向上打开 从一个轻微的变焦。只有剪辑路径和变换改变。followState(target, { attribute, states }):动画到 组件报告(例如:data-state从@lovince/ui-vue),所以菜单可以 门关着的时候,不要往外看。createPageTransition({ leave, enter, focus }):await leave(old)交换,enter(next). 每次通话结束,新的通话取消最后一个,然后集中注意力 移动到新的页面。
框架
香草。 在DOM存在后创建上下文,并调用
destroy() 当视图被移除时。
Vue / next。
<script setup lang="ts">
import { useMotion, useMotionEffect, vReveal } from '@lovince/motion/vue'
import { createSmoothScroll } from '@lovince/motion/scroll'
import { gsapScrollDriver } from '@lovince/motion/gsap'
import { heroEntrance } from '@lovince/motion/presets'
const root = ref<HTMLElement | null>(null)
useMotion((motion) => heroEntrance({ heading: 'h1', body: 'p' }, { context: motion }), { root })
const scroll = useMotionEffect(() => createSmoothScroll({ driver: gsapScrollDriver() }))
</script>
<template>
<section ref="root"><h1>Hello</h1><p v-reveal="'fade-up'">…</p></section>
</template>
一切从mount开始,在unmount时恢复。期间没有任何运行 服务器呈现。
反应。
useLayoutEffect(() => {
const motion = createMotion({ root: ref.current })
heroEntrance({ heading: 'h1' }, { context: motion })
return motion.destroy // Strict Mode's double run is safe: destroy reverts fully
}, [])
苗条的。
<script>
import { onMount } from 'svelte'
import { createMotion, revealOnScroll } from '@lovince/motion'
let root
onMount(() => {
const motion = createMotion({ root })
revealOnScroll('[data-reveal]', { context: motion })
return motion.destroy
})
</script>
表演
- 只有
transform,opacity而且,对于揭示,clip-path动画。 - 视差和固定测量通过ScrollTrigger的刷新,从来没有 滚动事件。只有当宽度改变时,才重新测量线分割。
- 没有永久的
will-change. GSAP每间隔设置一次。 - 一个时钟:当两个人都在场时,莱尼斯就会使用GSAP的计时器。
- 主条目小于6kb gzip,没有引擎。GSAP和Lenis只能加载 通过他们的条目。
- 每个原语小于1.5 KB gzip;他们都有
initMotion保持在6 KB以下。
测试
- 泄漏: 后
destroy(),ScrollTrigger.getAll().length回到原来的位置。 - 减少运动:
matchMedia是嘲笑;内容显示,没有分裂,循环保持不变,首选项更改重新运行。 - 令牌同步: CSS镜像会解析出与TypeScript令牌相同的值。
版本控制
Semver。更改令牌值是一个 主要的 改变,因为它改变了每个网站的感觉。新的标记、原语和属性是次要的。修复是补丁。在1.0之前,小版本可能会改变行为,发行说明也会这样说。
贡献
- 使用令牌,而不是原始的持续时间、时间或距离
- 动画转换和不透明度(或说明为什么不)
- 返回使内容可见的清理操作
- 处理减少的运动,并有一个测试
- 有泄漏测试
- 没有目标时从不扔
- 保持在它的规模预算之内
故障排除
- 内容保持隐藏: 这个
js类设置,但脚本失败。加载动作脚本之前,任何可能抛出,或放弃类。 - 在错误的地方引发火灾: 有什么东西在装载后改变了高度。呼叫
motion.refresh();createScroll一旦字体加载就会刷新。 - 平滑滚动与内部滚动对抗: 添加
data-lenis-prevent到它。 - 什么都不能动: 检查操作系统的运动减少设置。这是预期的行为。
- Marquee仍然是: 轨道还没有宽度,或者速度为0。给它内容
data-marquee-track.
许可证
麻省理工学院