@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 before1.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.
| Import | Needs | Gives |
|---|---|---|
@lovince/motion | — | createMotion, preferences, revealOnScroll, fadeIn, splitText, focusTarget, whenMotionAllowed, motion config and tokens |
@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 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 · astro | gsap + the framework | vMotion, 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'andquickTo, so a new gesture never waits for the old one. - Transform and opacity only, plus
clip-pathfor 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
| Group | Values | CSS |
|---|---|---|
| duration | xs 0.2 · sm 0.4 · md 0.7 · lg 1.1 · xl 1.6 (s) | --motion-duration-{xs…xl} |
| ease | out expo.out · inOut power3.inOut · in power2.in · soft power2.out · liquid (spring) | --motion-ease-{out,in-out,in,soft,glide,liquid} |
| distance | sm 12 · md 24 · lg 48 (px at 16px root, on the spacing scale) | --motion-distance-{sm,md,lg} |
| stagger | tight 0.04 · base 0.06 · loose 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 })
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:
| Pattern | Use for | Timing |
|---|---|---|
state | colour, border, background changes | micro, standard ease |
move | indicators, thumbs, pills changing place | morph, emphasized ease |
enter | popovers, toasts, items arriving | enter, emphasized ease |
exit | anything leaving | 0.65× enter, ease in |
liquid | segmented thumbs, tab pills, selections | overlay, spring settle |
scene | scroll reveals, heroes | scene.md, expo.out |
loop | marquees, spinners, shimmer | linear, 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()
| Attribute | Value | Does |
|---|---|---|
data-reveal | sm · md · lg · pop | fades up by that distance as it scrolls in; pop springs in |
data-split | lines · words | splits after fonts load; masked lines rise from their own edge |
data-scrub | dim opacity, default 0.18 | lights words up, scrubbed to scroll |
data-kinetic | tilt · letters · flip · blur · scramble · wipe · fill · slide · marker | kinetic type; fill and slide follow scroll, the rest play once; <mark> gets a drawn highlight (--kinetic-marker) |
data-parallax | speed, default 0.15 | drifts against the scroll |
data-magnetic | strength, default 0.3 | pulls toward the pointer, hover devices only |
data-marquee | px/s, default 40, negative reverses | seamless 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-headroom | offset px, default 420 | hides on scroll down, shows on scroll up or focus |
data-delay | seconds | added 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
| Engine | Owns |
|---|---|
| CSS | Hover, focus and press feedback, underlines, small transitions, decorative loops, simple scroll reveals |
| GSAP | Hero sequences, staggered and split typography, scroll-linked scenes, pinning, horizontal galleries, page transitions |
| Lenis | Smooth 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, anddestroy()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.reducedupdated. 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,scaleandclipmean the same in CSS reveals,fadeIn(Web Animations) and every GSAP API. - Tokens decide. Durations, curves and travel distances come from
@lovince/tokens.defaultMotionConfigusesduration.travelandease.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.
| API | Under reduced motion |
|---|---|
revealOnScroll, createScrollReveal | Content is shown; nothing is hidden |
fadeIn, createTimeline, presets | Jump to the end state; promises settle finished |
createParallax, createHorizontalScroll | Not created; the static layout stands |
revealText | The text is never split |
followState | States apply instantly |
createSmoothScroll | No 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. AscrollTriggeris 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. Keepspeedsmall.createHorizontalScroll(section, track, { minWidth }): pins atminWidthand 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 viaIntl.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.actionschildren 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-statefrom@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,opacityand, for reveals,clip-pathanimate. - 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
initMotionstay under 6 KB.
Testing
- Leaks: after
destroy(),ScrollTrigger.getAll().lengthreturns to where it was. - Reduced motion:
matchMediais 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
jsclass 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();createScrollalready refreshes once fonts load. - Smooth scroll fights an inner scroller: add
data-lenis-preventto 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