@joinamply/motion
v0.2.0
Published
Attribute-driven scroll animations for Webflow. Waits on the Amply library manager for GSAP; ships from jsDelivr as one script tag.
Readme
Amply Motion
Attribute-driven scroll animations for Webflow. [anim-group] on a wrapper, [anim="type"]
on each element — no timeline to wire, no Interactions panel.
Sunset when Webflow shipped ix3; back because ix3 didn't work out. The difference this time is delivery: it ships from a CDN as one script tag instead of a 300-line pasted embed, so a bug fix is a version bump rather than an edit on every client site.
Internal — strip on duplication. Like
framework-app/andinput-enhancer/, this directory does not ship to clients. Delete it when the starter is duplicated. Client sites consume the published package from jsDelivr; they never need the source.
Install on a page
Two embeds in Project Core, then the toggles:
| | Where | What |
|---|---|---|
| 1 | 🎨 Styles | webflow/styles-embed.html — the pre-hide CSS |
| 2 | ⚡ Codes | webflow/codes-embed.html — the script tag |
| 3 | toggles | GSAP → Main + ScrollTrigger, plus SplitText for text-fill |
The CSS stays on the page and never moves to the CDN. It is render-blocking-critical: it is what hides an element before GSAP can reach it. Served over the network it would flash the final content, or — if the request failed — hide copy permanently. The JS is allowed to be late; the CSS is not.
Animation types
| | |
|---|---|
| Reveals | slide-up slide-up-growing slide-down slide-left slide-right grow-in grow-out fade-in fade-out |
| Scrubbed | parallax-up parallax-down parallax-left parallax-right · text-fill |
| Other | counter |
<div anim-group anim-stagger="0.1">
<h2 anim="slide-up">Headline</h2>
<p anim="slide-up" anim-delay="0.1">Body copy</p>
<a anim="fade-in" anim-order="3">Call to action</a>
</div>Attributes
| Attribute | On | Default | Does |
|---|---|---|---|
| anim | element | — | the animation type |
| anim-group | wrapper | — | children stagger in on one timeline |
| anim-duration | element | 0.8 | seconds |
| anim-delay | element | 0 | seconds |
| anim-ease | element | power3.out | any GSAP ease |
| anim-distance | element | 40 (100 parallax) | px travelled |
| anim-stagger | group | 0.12 | seconds between children |
| anim-order | element | DOM order | explicit sequence; equal values start together |
| anim-once | group | true | "false" replays on scroll-back |
| anim-start | either | top 80% | ScrollTrigger start |
| anim-end | element | varies | ScrollTrigger end (scrubbed types) |
| anim-scrub | element | 2 | parallax catch-up, seconds |
anim-once goes on the wrapper, not the children — the group's timeline owns the toggle.
Counters read their target from their own text, then count to it: counter-duration,
counter-separator, counter-increment, on the number or its group.
text-fill dims to --anim-fill-dim (default 0.18) and brightens per character.
How GSAP gets here
It isn't bundled. GSAP arrives through the Amply library manager, gated by the boolean
props on Project Core, exactly like every other library on the site. Motion waits for
it with onLibrariesLoaded and reads the DOM to decide what to ask for — no flags to
remember, because [anim="text-fill"] on the page is the request for SplitText.
Three manager behaviours shape src/gate.ts, all verified against the shipped
embed on 2026-09-12:
- Late subscribers resolve — it checks
loadedLibrariesbefore attaching listeners, so we don't have to race its boot. - A disabled toggle never resolves — no
loadLibrarycall, no event, callback dropped forever. This is why Motion carries its own timeout: without it, one unchecked box means a page of permanently invisible text. - Plugins register in their own
Webflow.push— ours can be queued first, sowindow.ScrollTriggermay exist unregistered. Motion callsregisterPlugindefensively; it's idempotent.
Failure behaviour
Motion never leaves a page blank. Every path that can't animate reveals everything at rest
by putting .anim-fallback on <html>, and says why:
| Situation | What happens |
|---|---|
| prefers-reduced-motion: reduce | revealed at rest, silently — that's the point |
| No [anim] on the page | nothing runs; the manager is never even asked |
| Main or ScrollTrigger off | revealed after 4s + warning naming only the toggle that's actually missing |
| No Project Core on the page | revealed + warning telling you to add the component |
| webflow.js never drains the ready queue | revealed after 5s by the module-scope failsafe |
| SplitText off | everything else still animates. text-fill uses the built-in splitter and logs an informational note |
That last row matters: SplitText is the one optional dependency, so a forgotten checkbox
degrades one effect instead of disabling every animation on the page. The hard requirement is
gsap + scrolltrigger only.
The failsafe is armed at module scope, deliberately outside Webflow.push — a failsafe that
lives inside the callback can't save you from the callback never running, which is exactly
what happens if webflow.js is missing or blocked.
All six paths are covered by the harness (test/), one ?mode= each.
Development
npm install
npm run build # → dist/motion.js (IIFE, minified, sourcemapped)
npm run typecheck
npx serve . -l 4321 # harness → http://localhost:4321/test/IIFE rather than ESM: plugins come from the manager, so there is nothing left to code-split, and a plain script drops the module-ordering subtleties.
Publishing
The published bundle is public. jsDelivr cannot serve private npm packages, so there is no private-CDN path. The source directory stays internal and is stripped on duplication; the compiled bundle is world-readable once published. That is normal for this kind of library (Finsweet ship theirs the same way) but it is a deliberate choice, not an accident.
First time only — both steps are on npmjs.com and need the Amply account:
- Create the
@joinamplyorg (npmjs.com → Add organization). Free for public packages. npm login
Then, every release:
npm version patch # or minor / major
npm publish # prepublishOnly runs typecheck + build firstnpm publish --dry-run prints the exact tarball without sending anything — 6 files, ~20 kB.
Give jsDelivr a minute, then confirm the URL actually serves before pointing a site at it:
curl -sI https://cdn.jsdelivr.net/npm/@joinamply/[email protected]/dist/motion.js | head -1Then bump the pinned version in webflow/codes-embed.html and on any site that should
follow.
Pin exact versions everywhere — including the starter. An exact version is immutable on
jsDelivr and cached permanently; a @0.1 range has to be re-resolved and that resolution is
cached too, so a freshly published fix can take a long time to reach pages on the range and
you cannot tell which build a visitor actually ran. Bumping one line deliberately is worth
more than automatic upgrades you can't observe.
The attribute prefix
Kept as anim / anim-* — it's the existing API and reads well in the Designer. Collision
risk with another library is real but theoretical. If it ever bites, P in
src/config.ts is the single place the prefix is spelled; changing it to
am renames the whole surface.
