Can Animations for Webflow
A single self-contained JS bundle that renders 3D drink cans (Three.js) with
scroll- and time-driven animations (GSAP ScrollTrigger + Lenis), configured
entirely from Webflow via data attributes. One script tag site-wide; each page
opts in by adding an element with data-can attributes — no per-page code.
pnpm dev # sandbox at localhost:5173 (index.html is the test page)
pnpm build # dist/can.js (~203 KB gz) + models, the exact CDN upload set
Project structure
index.html sandbox page (hero → lock demo; not shipped)
public/*.glb models (served in dev, uploaded to CDN separately)
src/
main.js boot: find [data-can] mounts, parse attributes,
start Lenis once, wire resize → ScrollTrigger.refresh
core/
stage.js renderer + scene + camera + env map + lights,
render loop, px→world conversion. One per mount.
rig.js GLB loading, facing, centering, normalization;
builds the motion rig (see below)
wobble.js shared amp-gateable wobble (sin/cos form)
behaviors/
index.js attribute value → behavior module registry
ambient.js endless label spin (+ optional static slant)
wiggle.js idle wobble on the spot
lock.js hero wobble → scroll travel → lock into a DOM hole
The idea
Two rules carry the whole design:
1. Stage and rig build things; behaviors animate them.
core/ solves the hard 3D problems once (pivots, facing, normalization,
camera math). A behavior is pure choreography over parts it is handed:
behavior({ stage, rig, cfg, reduced }). Adding an animation style never
touches core/.
2. One node per motion — no two animations share a target property.
The rig built by rig.js is a chain of Groups, each owning exactly one
motion, so time-driven and scroll-driven tweens can never fight:
scene
└─ mover position/scale/yaw scroll-driven travel (lock)
└─ wobbleGroup rotation.x/z wobble (time phase × scroll amp) or static slant
└─ can rotation.y label spin (time or scroll)
└─ model static: facing fix, centering offset
└─ mesh(es) geometry + materials from the GLB
Where a rotation sits in this chain decides what it means: matrices apply to a vertex inner→outer, so the spin (below the lean) turns the can about its own tilted axis, while anything above the lean would just add to the sweep.
Key facts baked into rig.js, learned the hard way:
- A position offset cannot move a rotation pivot. Rotation happens around
an object's own origin; the model is centered inside a wrapper group so
the group's origin is the geometric center (3D's
transform-origin). - Every can is normalized to 1.5 world units, so camera framing and hole-fitting math hold for any asset.
facingis per-asset. Scans rarely center their label artwork at the geometric 180° flip (the Monster can is off by ~20°). Measure once per model — see "Adding a new model".- The wobble is sin/cos on x/z with a scroll-driven amplitude because that form has no Y component: amp = 0 contributes exactly nothing, giving lock a deterministic orientation no matter where the time phase happens to be.
Attribute API
<div data-can
data-can-src="https://cdn.example.com/models/monster_can.glb"
data-can-behavior="lock">
</div>
| Attribute | Default | Meaning |
|---|---|---|
data-can-src |
— (required) | GLB URL (host must send CORS headers) |
data-can-behavior |
ambient |
ambient | wiggle | lock |
data-can-facing |
180 |
deg — label-front yaw for THIS model |
data-can-tilt |
24 |
deg — wobble cone half-angle (wiggle/lock) |
data-can-slant |
0 |
deg — static lean while spinning (ambient) |
data-can-wobble-secs |
6 |
seconds per wobble cycle |
data-can-spin-secs |
12 |
seconds per ambient revolution |
data-can-hole |
.can-hole |
lock: selector of the element the can lands on |
data-can-trigger |
.lock-section |
lock: scroll trigger section selector |
data-can-lenis |
— | off = skip Lenis (site already runs its own) |
data-can-debug |
— | expose window.__can = {stage, rig, cfg, gsap} |
The mount's own CSS decides presentation: position: fixed; inset: 0; pointer-events: none behind the content for lock; any sized box for
ambient/wiggle product cards.
Lock additionally needs two elements on the page: the trigger section and the
hole element. The choreography runs while the trigger's top travels from
viewport bottom to viewport top; at that moment the can sits exactly over the
hole (measured live — the designer can move/resize .can-hole freely, and
resize re-measures via invalidateOnRefresh). The visual cutout is separate
artwork (e.g. a transparent div whose box-shadow: 0 0 0 200vmax <color>
paints the surround — see the sandbox); keep the hole slightly smaller than
the can so it crops the can rather than showing a background halo.
Webflow integration
- Publish the bundle to npm (below); upload the
.glbfiles to any CDN that sends CORS headers. - Site Settings → Custom Code → Footer:
<script src="https://cdn.jsdelivr.net/npm/canflow@1/dist/can.js" defer></script> - Per page: add a Div Block, style it, set the attributes in the element
panel (
data-can-src= the model's CDN URL). Nothing else.
Publishing the bundle (npm + jsDelivr)
The package ships only dist/can.js (see files in package.json —
models and _headers never enter the tarball). prepublishOnly rebuilds,
so a publish always ships a fresh bundle. Release flow:
npm version patch # or minor / major — bumps + git-tags
git push --follow-tags
npm publish # first time: npm login
jsDelivr serves every published version immediately and immutably:
…/npm/canflow@1/dist/can.js— floats within 1.x: patches reach all embedded sites automatically (~12 h lag), breaking 2.0 never does. Use this in Webflow.…/npm/canflow@1.2.3/dist/can.js— exact pin, cached forever.
Published versions cannot be altered — a bad release is fixed by publishing a patch, never by overwriting. No purge dance, ever.
Hosting models
Any static host works; requirements are just:
- CORS — the GLB is XHR-fetched from the Webflow origin, so the host
must send
Access-Control-Allow-Origin. (For Cloudflare Pages,public/_headershas a ready config; jsDelivr/gh sends it by default.) - New model version → new filename, so far-future caching stays safe.
General notes:
- Models are not bundled — deliberately. Base64-embedding 10 MB into JS adds 33 % and blocks parsing; models and code version independently; pages only load the models they use.
- The Draco decoder loads from Google's CDN (
gstatic.com); copy the decoder files intopublic/and change the path inrig.jsfor zero third-party. - Build uses esbuild directly, not
vite build: Vite 8 (rolldown) lib mode produced a 3.3× bloated IIFE (677 KB gz vs 203 KB) from identical source. Vite stays for dev. Re-measure before switching back. - The build defines
import.meta.url(see package.json): IIFE scripts have noimport.meta, and DRACOLoader constructs URLs from it at module scope — without the define the bundle throwsInvalid URLon load (dev never hits this; ESM has realimport.meta). The value points at three's package on jsDelivr so DRACOLoader's fallback decoder paths resolve to real files — keep its version in lockstep when upgradingthree. After any build change, smoke-test the real bundle viatest-dist.html(serve the repo root), not just the Vite dev page.
Adding a new behavior
Create
src/behaviors/spiral.js:import gsap from 'gsap'; export default function spiral({ stage, rig, cfg, reduced }) { if (reduced) return; // skip ambient motion; scroll-driven is fine to keep gsap.to(rig.can.rotation, { /* ... */ }); }Register it in
src/behaviors/index.js.Use it:
data-can-behavior="spiral".
Rules of the road: animate only via the rig nodes and stage lights; one
tween per property; time-based loops use ease: 'none' (eased loops lurch at
the seam); with scrub, duration means relative scroll distance and eases
reshape value-vs-scroll, not value-vs-time.
Adding a new model
- Optimize the GLB:
npx @gltf-transform/cli optimize in.glb out.glb(Draco). Target well under 20 MB (jsDelivr per-file cap), ideally 2–3 MB. - Upload; point
data-can-srcat it. - Measure facing: add
data-can-debug, open the console, and nudge__can.rig.can.rotation.yuntil the label faces the camera; setdata-can-facing= 180 + that offset in degrees. (Or fix the origin in Blender and leave facing at 180.) - Materials for steel: metalness ≈ 1, roughness ≈ 0.2; the env map in
stage.jsdoes the rest.
Gotchas
- Two Lenis instances break scrolling. If the Webflow site already runs
Lenis globally, set
data-can-lenis="off"on a mount. prefers-reduced-motionis respected: ambient loops are skipped (static slant still applies — it's presentation, not motion); scroll-driven animation stays, since the user drives it.- Each mount = one WebGL context; browsers allow ~8–16 per page. Keep it to a few.
- Animations (and rendering) pause automatically in hidden tabs — rAF stops. That's desirable; don't "fix" it.
window.__canonly exists withdata-can-debug; remove the attribute in production.
Status / backlog
Working and verified: all three behaviors; lock's travel/scale/yaw math (incl. perspective compensation for off-center holes); resize re-measuring; reduced-motion branch; esbuild production build.
Not done yet: loading state (can pops in when the GLB arrives), real can asset (current Monster scan is a placeholder needing a Blender pass), HDRI environment option (RGBELoader) if the site's mood needs it, duplicate-GSAP check on the target Webflow site, traced-silhouette cutout artwork for lock.