Install
npx skillscat add weblikha-digital/avpn/skills-webflow-animation-embed Install via the SkillsCat registry.
Webflow Animation Embed
Use this when adding, changing, or shipping animation code from this repo into the
AVPN Webflow site.
Context
- Webflow owns layout, styling, and native Interactions. This repo only owns JS for
motion that Webflow's Designer can't express (scroll-driven sequences, custom
easing/staggers, draggable/inertia sliders, anything needing GSAP). - No CMS is in play — every page is static in Webflow, so there's no templating step
between this repo and the live site. - Each animated component is tagged in the Webflow Designer with a namespaced
data-<component>-*attribute set (Element Settings → custom attributes). No
Webflow classes or IDs are required by the script.
Component convention
Every non-trivial animation is one self-contained init<Component>() function, not
a small helper module. Follow this shape:
Global setup at module scope, once.
gsap.registerPlugin(...)and anyCustomEase.create(...)calls go at the top of the file, outside any function —
they're side effects that only need to run once, not per init call.One function drives every instance on the page.
document.querySelectorAll('[data-<component>-init]').forEach(container => {...})
insideinit<Component>()— never assume a singleton. This is also what makes
the function safely re-callable (e.g. from a resize handler).Idempotent re-init guard, first thing inside the loop. Before building
anything, tear down previous state: kill old tweens/draggables stored on the
element (container._xyzInstance?.kill()), disconnect old observers,clearPropson transformed elements, remove generated clones. State lives on
the DOM node itself (container._camelCasedProp), not a module-levelMap—
this keeps multiple instances independent and colocates cleanup with setup.Early-return on missing markup, not thrown errors —
if (!requiredChild) return;so one broken instance doesn't break the page.Tunables come from CSS custom properties on the container (e.g.
--slider-curve), not hardcoded JS constants, wherever whoever owns the
Designer/CSS should be able to adjust behavior without touching this repo.The entire DOM contract is namespaced
data-<component>-*attributes — init
hook, sub-elements, controls, status flags. Never rely on Webflow-generated
class names; they change whenever someone edits the Designer.Resize handling, if needed, is self-deduping. Stash the debounced listener
on the init function itself (e.g.initX._resize), remove the old one before
adding a new one, and only fire on actual width change (ignore mobile
viewport-height jitter from URL bar show/hide).Accessibility is set programmatically (ARIA roles/labels/
aria-current)
since Webflow's Designer doesn't give a clean way to author these — set them in
the same pass that builds the component.Define, then invoke. The full function body comes first; the only call site
is a singledocument.addEventListener('DOMContentLoaded', () => init<Component>())
at the bottom of the file. Don't invoke inline where it's defined — keep
declaration and execution visually separate so the file reads top-to-bottom as
spec-then-run.Prioritize a ticker callback that something else reads. If a component writes
per-frame state that a ScrollTrigger then reads — the horizontal band writingviewport.scrollLeftfor the triggers scoped to it is the case in this repo — add
it with the prioritize flag:gsap.ticker.add(render, false, true);Plain
gsap.ticker.add(render)appends to the tail of the queue. That is fine on
first load, but a re-init removes the callback and re-adds it behind ScrollTrigger's
own ticker listener, which is registered once and never moves. The read then happens
before the write and every dependent trigger runs one frame stale — invisible while
the page is still, a few-pixel shimmy while it scrolls. Anything that re-initializes
on resize is exposed to this.
Steps
- Add or edit a component file in
src/animations/, following the convention
above, keyed off its owndata-<component>-initattribute. - Wire it up in
src/index.js(import + call its init function onDOMContentLoaded, or let the component call itself if it's self-invoking). - Add a preview block with the matching
data-*attributes toindex.html,
thennpm run devfor a fast local preview with HMR;npm run buildfor the
final bundle. - Commit the updated
dist/animations.min.js— it's the deliverable, not an
artifact to gitignore. - Wire the bundle into Webflow — see "Local dev against Webflow" below.
- In the Webflow Designer, add the matching
data-<component>-*attributes to
whichever elements make up that component, then Publish.
Local dev against Webflow
npm run webflow rebuilds dist/ on save and serves it athttp://localhost:4173/animations.min.js. The AVPN site's footer custom code
already points there, so the loop is: save → refresh the published page.
That server is only for a person's local-development browser. The automatednpm run test:e2e:live run builds once in global setup and has each spec fulfil
the footer's bundle request directly from dist/; it never starts, needs, or
reuses port 4173.
Port 4173 is fixed and single-owner. The published page names it, so it cannot
move, and only one checkout may serve it at a time — the main checkout when
working in Warp, or the active Conductor workspace when working there. Check it
is free with lsof -nP -iTCP:4173 -sTCP:LISTEN before starting, and stop
whatever holds it rather than starting a second server. Never copy workspace
files into the main checkout to test them; serve the workspace's own bundle.
Publish once after the script tag is added; after that, JS changes need no
republish. Custom code doesn't run in the Designer canvas, so test againstavpn-25-26.webflow.io, not Designer preview.
Two traps worth knowing:
- Don't serve
dist/withnpm run dev. Vite's dev server rewrites JS under
the project root — the bundle comes back ~3.4MB of transformed modules instead
of the 638KB that ships.npm run webflowserves it statically instead. - Safari blocks this; Chrome and Firefox don't. An
https://page loadinghttp://localhostis allowed only because localhost is a potentially-trustworthy
origin, and Safari doesn't grant that exemption.
For production, register a hosted script with an SRI hash against a versioned CDN
URL. That's why dev uses freeform footer code instead: Webflow's registered-script
API requires the hash, which would change on every rebuild.
Constraints
- Keep it lean: one file per component, no framework, no build step beyond esbuild.
- Don't rely on Webflow-generated class names — data attributes are the stable
contract between Webflow and this repo. - Prefer state on the DOM node over module-level state, so components stay
independently re-initializable.