npm.io
1.0.0 • Published 2d ago

canflow

Licence
MIT
Version
1.0.0
Deps
3
Size
747 kB
Vulns
0
Weekly
0

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.
  • facing is 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

  1. Publish the bundle to npm (below); upload the .glb files to any CDN that sends CORS headers.
  2. Site Settings → Custom Code → Footer: <script src="https://cdn.jsdelivr.net/npm/canflow@1/dist/can.js" defer></script>
  3. 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/_headers has 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 into public/ and change the path in rig.js for 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 no import.meta, and DRACOLoader constructs URLs from it at module scope — without the define the bundle throws Invalid URL on load (dev never hits this; ESM has real import.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 upgrading three. After any build change, smoke-test the real bundle via test-dist.html (serve the repo root), not just the Vite dev page.

Adding a new behavior

  1. 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, { /* ... */ });
    }
  2. Register it in src/behaviors/index.js.

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

  1. 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.
  2. Upload; point data-can-src at it.
  3. Measure facing: add data-can-debug, open the console, and nudge __can.rig.can.rotation.y until the label faces the camera; set data-can-facing = 180 + that offset in degrees. (Or fix the origin in Blender and leave facing at 180.)
  4. Materials for steel: metalness ≈ 1, roughness ≈ 0.2; the env map in stage.js does 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-motion is 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.__can only exists with data-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.

Keywords