@lovince/motion

中文文档
0.4.0发布于 2026/11/02MITTypeScriptES Module

A framework-agnostic motion system: native CSS reveals, managed GSAP timelines and scroll scenes, and Lenis smooth scrolling — with mandatory cleanup and reduced-motion handling.

原始使用文档
文档目录23 节
  1. Entries
  2. Principles
  3. Tokens
  4. Patterns
  5. Primitives
  6. Page transitions
  7. Adapters
  8. Who owns what
  9. Quick start
  10. The model
  11. Reduced motion
  12. Progressive enhancement
  13. CSS layer
  14. GSAP layer
  15. Smooth scrolling
  16. Presets
  17. Frameworks
  18. Performance
  19. Testing
  20. Versioning
  21. Contributing
  22. Troubleshooting
  23. License

@lovince/motion

A framework-agnostic motion system. CSS handles light interface motion, GSAP orchestrates timelines and scroll scenes, and Lenis provides smooth scrolling. Every animation has an owner, a cleanup and a reduced-motion policy.

Status: 0.4.0, experimental. The API may change before 1.0.

bun add @lovince/motion
bun add gsap lenis      # only for the entries that use them

The goal is not to animate everything. It is to make the motion a site does have consistent, interruptible and accessible.

Entries

Each engine sits behind its own entry, so an app pays only for what it imports and a missing optional peer breaks nothing else.

ImportNeedsGives
@lovince/motion—createMotion, preferences, revealOnScroll, fadeIn, splitText, focusTarget, whenMotionAllowed, motion config and tokens
@lovince/motion/gsapgsapcreateTimeline, createStagger, createScrollReveal, createParallax, createHorizontalScroll, gsapScrollDriver
@lovince/motion/scrollleniscreateSmoothScroll
@lovince/motion/presetsgsapheroEntrance, revealText, revealImage, followState, createPageTransition
@lovince/motion/vuevueuseMotion, useMotionEffect, v-reveal
@lovince/motion/primitivesgsapinitMotion and every data-attribute primitive
@lovince/motion/primitives/<name>gsap (scroll also lenis)one primitive: reveal, split, scrub, parallax, magnetic, marquee, liquid, ripple, progress, headroom, transition, scroll
@lovince/motion/adapters/vue · react · svelte · astrogsap + the frameworkvMotion, useMotion, the motion action, bindAstro
@lovince/motion/styles.css—tokens + utilities + presets + primitive styles (or tokens.css, utilities.css, presets.css, primitives.css on their own)

gsap, lenis, vue and react are optional peers, so your app controls their versions and there is never a second copy at runtime.

Principles

  • Restraint. Motion explains a change; it never decorates for its own sake. Travel 12–48px.
  • Ease out to enter, ease in to leave. Exits run at exitRatio (0.65×) of the entrance.
  • Interruptible. Tweens use overwrite: 'auto' and quickTo, so a new gesture never waits for the old one.
  • Transform and opacity only, plus clip-path for reveals and the liquid indicator.
  • Tokens, never raw numbers. Durations, eases, distances and staggers come from @lovince/tokens.
  • Reduced motion is a first-class mode, not an afterthought: content shows at once and loops stay still.
  • Every primitive returns a cleanup.

Tokens

GroupValuesCSS
durationxs 0.2 · sm 0.4 · md 0.7 · lg 1.1 · xl 1.6 (s)--motion-duration-{xs…xl}
easeout expo.out · inOut power3.inOut · in power2.in · soft power2.out · liquid (spring)--motion-ease-{out,in-out,in,soft,glide,liquid}
distancesm 12 · md 24 · lg 48 (px at 16px root, on the spacing scale)--motion-distance-{sm,md,lg}
staggertight 0.04 · base 0.06 · loose 0.1 (s)--motion-stagger-{tight,base,loose}
exitRatio0.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 })

A test keeps the CSS mirror and the TypeScript values in sync.

Patterns

The Lovince interface already moves in a handful of recognisable ways. motionPatterns names them so new work matches:

PatternUse forTiming
statecolour, border, background changesmicro, standard ease
moveindicators, thumbs, pills changing placemorph, emphasized ease
enterpopovers, toasts, items arrivingenter, emphasized ease
exitanything leaving0.65× enter, ease in
liquidsegmented thumbs, tab pills, selectionsoverlay, spring settle
scenescroll reveals, heroesscene.md, expo.out
loopmarquees, spinners, shimmerlinear, paused when off screen or asked

Primitives

Mark up the page, then initialise once:

<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()
AttributeValueDoes
data-revealsm · md · lg · popfades up by that distance as it scrolls in; pop springs in
data-splitlines · wordssplits after fonts load; masked lines rise from their own edge
data-scrubdim opacity, default 0.18lights words up, scrubbed to scroll
data-kinetictilt · letters · flip · blur · scramble · wipe · fill · slide · markerkinetic type; fill and slide follow scroll, the rest play once; <mark> gets a drawn highlight (--kinetic-marker)
data-parallaxspeed, default 0.15drifts against the scroll
data-magneticstrength, default 0.3pulls toward the pointer, hover devices only
data-marqueepx/s, default 40, negative reversesseamless loop; pauses on hover, focus, off screen and on data-marquee-toggle (WCAG 2.2.2)
data-liquid—a pill that stretches across both items, squashes, overshoots and settles on the active one
data-ripple—a ring expands from the press point
data-scroll-progress—scaleX follows page progress
data-headroomoffset px, default 420hides on scroll down, shows on scroll up or focus
data-delaysecondsadded to a reveal or split
data-lenis-prevent—lets an inner scroller scroll natively

Every primitive is also callable on its own, as (root?, options?) => Cleanup:

import { liquid } from '@lovince/motion/primitives/liquid'
const stop = liquid('#filters')

The liquid indicator follows whatever the component already reports: aria-selected, aria-pressed, aria-checked, aria-current or a data-state of on, active or checked, so it works with Lovince tabs, toggle groups and segmented controls without wiring.

FOUC guard. With a js class on <html>, reveal and split targets start hidden in CSS, so they never flash before the script runs. Without JavaScript, under reduced motion and after cleanup they are visible. initMotion adds the class if it is missing.

Page transitions

transition() follows one order, so scroll, triggers and focus never disagree:

stop scroll → exit → destroy old primitives → swap → scroll to top → init → refresh → start scroll → enter

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)) })

Adapters

<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>

Who owns what

EngineOwns
CSSHover, focus and press feedback, underlines, small transitions, decorative loops, simple scroll reveals
GSAPHero sequences, staggered and split typography, scroll-linked scenes, pinning, horizontal galleries, page transitions
LenisSmooth scrolling, as one service per scroller, on GSAP's clock when both are used

Give each animation one owner. Never animate the same element with CSS and GSAP at once.

Quick start

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()

The model

  • createMotion() is the owner. It scopes selectors to a root, holds every effect and cleanup, and destroy() reverts them all, newest first. Each API takes { context }. Without one, the call gets its own context and returns a cleanup.
  • Effects re-run when the preference changes. An effect sets up and returns its undo. If the user switches reduced motion on or off while the page is open, the effect is undone and run again with scope.reduced updated. Progress carries over, so a finished entrance never replays.
  • Outcomes, not timeouts. Anything that ends returns a promise that settles once, 'finished' or 'cancelled', and never rejects. Destroying the owning context cancels it. Nothing waits on a timer.
  • One vocabulary. The entrances fade, fade-up, fade-down, slide-left, slide-right, scale and clip mean the same in CSS reveals, fadeIn (Web Animations) and every GSAP API.
  • Tokens decide. Durations, curves and travel distances come from @lovince/tokens. defaultMotionConfig uses duration.travel and ease.out (power3.out), and the CSS --motion-* properties are aliases of --lovince-* ones, so CSS and GSAP move alike.
const config: MotionConfig = { duration: 0.72, ease: 'power3.out', stagger: 0.08, respectReducedMotion: true }

Reduced motion

One central policy: under prefers-reduced-motion: reduce, motion is replaced by its end state, not merely shortened.

APIUnder reduced motion
revealOnScroll, createScrollRevealContent is shown; nothing is hidden
fadeIn, createTimeline, presetsJump to the end state; promises settle finished
createParallax, createHorizontalScrollNot created; the static layout stands
revealTextThe text is never split
followStateStates apply instantly
createSmoothScrollNo Lenis; native scrolling, switching live

The stylesheet has the same rules, so CSS-only motion respects it too. Set respectReducedMotion: false only for motion that carries meaning.

Progressive enhancement

Content is never hidden by CSS alone. A reveal hides an element only by setting data-motion-state="hidden" from script, and cleanup removes it. If JavaScript, GSAP or the stylesheet fails to load, the page is simply static. On the server no effect runs, and every entry imports safely.

CSS layer

<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 }) uses IntersectionObserver, with no GSAP. Elements entering together get a --motion-delay stagger. fadeIn(targets, { effect, distance, duration, stagger }) runs a Web Animations entrance with fill: backwards, so nothing lingers afterwards.

Utilities: .motion-link, .motion-lift, .motion-press, .motion-fade-up, .motion-fade-in, .motion-scale-in, .motion-spin, .motion-pulse. All of them are opt-in classes, and none touches an element that didn't ask for it.

GSAP layer

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 }): tweens are reverted on destroy. A scrollTrigger is dropped under reduced motion.
  • createStagger(targets, { effect, stagger, onScroll }): a collection, in document order.
  • createScrollReveal(targets, { effect, start, repeat }): batches items entering together.
  • createParallax(targets, { speed, axis }): linear, scrubbed, measured once. Keep speed small.
  • createHorizontalScroll(section, track, { minWidth }): pins at minWidth and wider; narrower screens keep the plain layout. Tabbing to an off-screen item scrolls it into view.
  • splitText(element, { by, mask, onResplit }): words, chars or lines. It keeps inline markup, handles any script via Intl.Segmenter, reads whole words to screen readers, and re-splits lines when the width changes. revert() restores the original nodes.

ScrollTrigger is registered on first use, not at import.

Smooth scrolling

const scroll = createSmoothScroll({ driver: gsapScrollDriver(), lenis: { lerp: 0.1 } })
scroll.scrollTo('#pricing', { offset: -64 })
scroll.stop() // e.g. while a modal is open

There is one service per scroller. A second call for the same scroller shares the instance, and the last destroy() tears it down, so components can each ask for it safely. With gsapScrollDriver(), Lenis runs on GSAP's ticker and keeps ScrollTrigger in sync: one frame loop, not two. Without a driver, Lenis runs its own loop. Prefer native scrolling for apps, long forms and content-heavy pages.

Presets

  • heroEntrance({ label, heading, body, actions, media }): reading order, overlapped into one entrance. actions children stagger.
  • revealText(target, { by: 'words' | 'lines' | 'chars', onScroll }): line reveals rise from behind a mask, and rebuild where they were on resize.
  • revealImage(frames, { scale }): the frame opens upward while the image settles from a slight zoom. Only clip-path and transform change.
  • followState(target, { attribute, states }): animates to whatever state the component reports (e.g. data-state from @lovince/ui-vue), so a menu can never look open while it is closed.
  • createPageTransition({ leave, enter, focus }): await leave(old), swap, enter(next). Every call settles, a new call cancels the last, and focus moves into the new page.

Frameworks

Vanilla. Create a context after the DOM exists, and call destroy() when the view is removed.

Vue / Nuxt.

<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>

Everything starts on mount and is reverted on unmount. Nothing runs during server rendering.

React.

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
}, [])

Svelte.

<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>

Performance

  • Only transform, opacity and, for reveals, clip-path animate.
  • Parallax and pinning measure through ScrollTrigger's refresh, never per scroll event. Line splits re-measure only when the width changes.
  • No permanent will-change. GSAP sets it per tween.
  • One clock: Lenis rides GSAP's ticker when both are present.
  • The main entry is under 6 KB gzip with no engine. GSAP and Lenis load only through their entries.
  • Each primitive is under 1.5 KB gzip; all of them with initMotion stay under 6 KB.

Testing

  • Leaks: after destroy(), ScrollTrigger.getAll().length returns to where it was.
  • Reduced motion: matchMedia is mocked; content shows, nothing splits, loops stay still, and preference changes re-run live.
  • Token sync: the CSS mirror resolves to the same values as the TypeScript tokens.

Versioning

Semver. Changing a token value is a major change, because it changes how every site feels. New tokens, primitives and attributes are minor. Fixes are patches. Before 1.0 a minor may change behaviour, and the release notes say so.

Contributing

  • Uses tokens, not raw durations, eases or distances
  • Animates transform and opacity only (or says why not)
  • Returns a cleanup that leaves content visible
  • Handles reduced motion, and has a test for it
  • Has a leak test
  • Never throws when a target is missing
  • Stays inside its size budget

Troubleshooting

  • Content stays hidden: the js class is set but the script failed. Load the motion script before anything that can throw, or drop the class.
  • Triggers fire in the wrong place: something changed height after load. Call motion.refresh(); createScroll already refreshes once fonts load.
  • Smooth scroll fights an inner scroller: add data-lenis-prevent to it.
  • Nothing moves: check the OS reduced-motion setting. That is the intended behaviour.
  • Marquee is still: the track has no width yet, or speed is 0. Give it content and data-marquee-track.

License

MIT

版本与发布记录

@lovince/motion 共发布 5 个版本,首次发布于 2026/10/16;过去 12 个月发布了 5 个版本。

版本发布日期距今
0.4.0latest2026/11/021 天前
0.3.02026/11/021 天前
0.2.02026/11/012 天前
0.1.02026/10/1618 天前
0.0.0-stage2026/10/1618 天前

关于 @lovince/motion 的常见问题

@lovince/motion 是什么?

与框架无关的运动系统:原生CSS显示,管理GSAP时间线和滚动场景,以及Lenis平滑滚动-强制清理和减少运动处理。

如何安装 @lovince/motion?

在项目目录运行 npm install @lovince/motion,或使用 pnpm add / yarn add / bun add。

@lovince/motion 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@lovince/motion 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@lovince/motion 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@lovince/motion 可以商用吗?

@lovince/motion 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@lovince/motion 还在维护吗?

最新版本 0.4.0 发布于 2026/11/02(1 天前);至今共发布 5 个版本,其中过去 12 个月发布了 5 个;首次发布于 2026/10/16。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。