canflow
v1.1.0
Published
3D can animations for Webflow — Three.js + GSAP ScrollTrigger, configured entirely via data attributes. One script tag; behaviors: ambient, wiggle, drift, story.
Maintainers
Readme
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 # dev server at localhost:5173 (see the test pages below)
pnpm build # dist/can.js — the published bundle (~161 KB gz)Test pages (dev only, not shipped):
home.html— the full single-mount story: hero + blue/black switcher → blue lock → blue carousel → swap → black lock → black carousel.drift.html— the parallax drift behaviour (live on the client site).ambient.html— endless label spin.switch.html— the model roster / interchange in isolation.test-dist.html— smoke-tests the BUILT bundle + global GSAP (catches IIFE-only bugs); serve the repo root, not the Vite page.
The idea
Two rules carry the whole design:
1. Stage and rig build things; behaviours animate them.
core/ solves the hard 3D problems once (pivots, facing, normalization, camera
math, model loading). A behaviour is pure choreography over parts it is handed:
behaviour({ 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 (drift, story)
└─ wobbleGroup rotation.x/z wobble or static slant/lean
└─ can rotation.y label spin (time and/or scroll)
└─ model static: facing fix, centering offset
└─ mesh(es) geometry + materials from the GLBWhere a rotation sits decides what it means: matrices apply inner→outer, so the spin (below the lean) turns the can about its own tilted axis.
Key facts baked into core/:
- A position offset cannot move a rotation pivot. The model is centred
inside a wrapper group so the group's origin is the geometric center (3D's
transform-origin). Seemodels.js. - Every can is normalized to 1.5 world units, so camera framing and hole-fitting math hold for any asset.
facingis per-asset — a clean Blender export usually needs0; a scan rarely centres its label at 180°. Measure once (see "Per-model setup").- Materials render
FrontSide(forced inmodels.js). The cans exportDoubleSide, which is fine while opaque but shows the can's interior through a semi-transparent wall mid-dissolve.
Attribute API
<div data-can
data-can-src="https://cdn.example.com/models/can.glb"
data-can-behavior="drift"
data-can-env="dark"
data-can-poster="https://cdn.example.com/posters/can.png">
</div>| Attribute | Default | Meaning |
| ----------------------- | --------------- | ------------------------------------------------ |
| data-can-src | — (required) | GLB URL (host must send CORS headers) |
| data-can-behavior | ambient | ambient | wiggle | drift | story |
| data-can-facing | 180 | deg — label-front yaw for THIS model (0 for a clean export) |
| data-can-poster | — | image shown while the model loads (auto slant-matched) |
| data-can-poster-scale | 1 | nudge the poster size to match the 3D framing |
| data-can-env | light | environment preset: light | dark (set ONCE, not runtime-switchable) |
| data-can-env-intensity| 1 | reflection strength multiplier |
| data-can-exposure | 1 | overall brightness (tone-mapping exposure) |
| data-can-slant | 0 | deg — static lean while spinning |
| data-can-tilt | 24 | deg — wobble cone half-angle (wiggle) |
| data-can-wobble-secs | 6 | seconds per wobble cycle (wiggle) |
| data-can-spin-secs | 12 | seconds per idle revolution |
| data-can-from | .can-from | drift: idle anchor |
| data-can-to | .can-to | drift: anchor the can drifts to |
| data-can-story | — | story: JSON array of segments (see "story anatomy") |
| data-can-models | — | JSON roster of swappable models (see "Model roster") |
| data-can-range | — | selector; mount only renders while this element is in view (multi-mount pages) |
| data-can-set (on OTHER elements) | — | a page button carrying this switches the can to that roster key on click |
| data-can-lenis | — | off = skip Lenis (site already runs its own). One mount opting out disables Lenis for the WHOLE page. |
| data-can-loop | — | forward-only wrap: scroll max snaps back to 0. See "Page loop" |
| data-can-debug | — | expose window.__can = {stage, rig, cfg, gsap} (+ window.__story, window.__drift) |
The mount's own CSS decides presentation: position: fixed; inset: 0;
pointer-events: none behind the content for drift/story; any sized box for
ambient/wiggle product cards. Match data-can-env to the page behind the
canvas (dark for near-black backgrounds) or the metal looks composited.
The behaviours
- ambient — endless label spin;
data-can-slantleans it while it turns. - wiggle — idle precession wobble on the spot (no scroll).
- drift — idle spin that scroll hastens, while the can drifts and scales between a from and to anchor as you scroll between them (parallax). No landing; reverse drifts it home. Size the anchor elements to set the can size at each end.
- story — the whole page as one can. A sequence of scroll-scoped segments on a single mount: the can travels the entire scroll, docking into portals, dissolving, re-materialising in carousels, and swapping model between worlds. See the anatomy below.
story.js anatomy
story is the home page: one full-viewport mount whose can is threaded through
the whole scroll as a sequence of segments declared in data-can-story.
Instead of a mount (WebGL context + camera + env map) per world, one can changes
model and behaviour as you scroll — a mount is the expensive unit, a model is
cheap (fetched/parsed once, cached across the page).
Segments
data-can-story is a JSON array. Segments alternate idle and lock:
[
{ "kind": "idle" },
{ "kind": "lock", "trigger": ".lock-blue", "at": ".lock-blue .can-hole",
"portal": ".lock-blue .portal", "fit": "contain",
"model": "day", "reset": ".intro" },
{ "kind": "idle", "trigger": ".carousel-blue", "at": ".carousel-blue .slot",
"enter": "fade" },
{ "kind": "lock", "trigger": ".lock-black", "at": ".lock-black .can-hole",
"portal": ".lock-black .portal", "fit": "contain",
"model": "black", "reset": ".interlude" },
{ "kind": "idle", "trigger": ".carousel-black", "at": ".carousel-black .slot",
"enter": "fade" }
]| Field | On | Meaning |
| --------- | ----- | ------- |
| kind | both | idle (a rest anchor) or lock (an arrival into a portal). |
| at | both | idle: the rest anchor · lock: the portal hole. Omit on the first idle = screen centre (the hero). |
| trigger | both | the ~100vh section whose scroll drives this segment's transition. |
| portal | lock | element flagged .is-locked once the can has fully dissolved — the page reveals off it. |
| fit | lock | cover crops the can into the hole · contain fits it inside a slimmer framed recess. |
| enter | idle-after-a-lock | how the can enters this carousel: fade (default) or travel (see continuity). |
| model | lock | roster key to swap to before this lock (with reset). |
| reset | lock | selector of a dead-zone section where model swaps in, unseen, before the arrival. |
| slant | idle | deg — this rest's lean, overriding data-can-slant. |
| spin | idle | real-time label spin at this rest. Default: the first idle (the hero) only. |
| spinFrom| idle | 0–1, default 0.6 — how far into the entry the spin starts. See below. |
Anchors and portals are read live every frame, so resize/zoom/scroll need no bookkeeping. The mount is a fixed full-viewport layer; the sections and anchors are separate DOM markers the can measures.
The chain and its transitions
At load, the flat segment list is resolved into a chain: each lock remembers the idle it arrives from (its rest anchor), and each carousel remembers the lock it follows. Scroll drives one scrubbed transition per lock (its arrival) and per carousel (its entry); rests fill the gaps around them. At any scroll position, exactly one transition — or one rest — writes the rig (the one-writer rule, extended over scroll). Sections must be adjacent ~100vh blocks so the transition ranges stay disjoint.
A lock's arrival is the hero-lock gesture: over one span and one ease, the
can turns to face front, straightens its lean, flies to the portal hole, and
scales into it — every property reading the same arrive scalar so they land
together (splitting them reads as "turn, THEN shrink"). The dissolve lives in the
tail, and the tail is split: opacity reaches 0 at FADE_END (0.94) and the scroll
past it belongs to the page, which is what lets the popout start only once the can
is gone. .is-locked is derived from opacity hitting 0. Exactly one portal is
.is-locked at a time.
The spin model (why it doesn't skip on reverse)
Reconciling a free-running idle spin with a scroll-deterministic dock is the one genuinely hard part. The rule:
- The hero spins in real time — it wants life, and being first it never reverses into a lock, so it has no handoff to get wrong.
- Each lock derives its yaw from scroll: it turns from its incoming angle to
front (a
TAUmultiple), landing at a fixedlanded. - Each carousel HOLDS its lock's
landedfront angle — deterministic, no drift ("not doing anything"). Because the held angle equals the lock's landing at the boundary, scrubbing across it is seamless both ways.
The ease follows the same reasoning: the first lock arrives from the hero's
live spin, so it begins at speed (power2.out); later locks arrive from a
static carousel, so they ease in from rest (power2.inOut).
spinFrom — when a rest comes alive. A transition's scroll range opens when
its section's top crosses the viewport bottom, so at p = 0 that section is
still a full viewport away. Ungated, a spinning rest is already turning while you
are scrolling toward it. spinFrom (default 0.6) holds the rate at zero for
the first part of the entry; the can moves across the whole span as before and
only comes alive once it has arrived. Set 0 for the old behaviour.
It is gated on the raw scroll progress, not the eased one: the entry ease is
power2.out, already ~0.27 at p = 0.1, so easing the gate too would put the
can at a quarter rate while the section is barely on screen — the thing this
exists to prevent.
Click the can to read the label
A spinning rest will eventually park the label facing away. Clicking the can
turns it forward to the next front (idleAngle % TAU === 0, the same front
an arrival lands on) and holds it there; clicking again resumes. Never reverses —
the can turns one way everywhere else, and a backward correction reads as a bug.
The mount is a full-viewport pointer-events: none layer, so the canvas cannot
take the click without swallowing every click on the site. Instead the bundle
appends one transparent <button class="can-hit"> sized to the can's actual
on-screen box, pointer-events: auto, tracking it every frame. A real button, so
it is keyboard reachable and announced for free.
Where it is live: wherever the yaw is AMBIENT. There are two spins in this system and only one of them can strand the label:
- ambient —
advanceYaw(rate, deltaMs), time-driven. Stop scrolling and the can keeps turning. Every rest withspin: true, and the moves between two such rests, since those keep advancing it. - scroll-derived — inside a lock arrival,
idleAngle = base + turn * arrive. Stop scrolling and it stops dead. Nothing to settle, so the control is gone.
Note this is not the same as atRest below. Transition ranges are adjacent
(clamp(top bottom) → clamp(bottom bottom)), so an intermediate idle — a stat
band, a footer — has no scroll length of its own and is essentially never at
p = 0. Gate the control on "at rest" and it appears only on the hero, which is
the one rest with real scroll behind it. Gate it on ambient and it lands on
every segment that can actually turn the label away.
It also never exists under prefers-reduced-motion, where nothing spins at all,
and releases any hold whenever it disappears.
If it ever overlaps page controls of your own, .can-hit { pointer-events: none }
opts out without touching the bundle.
can:rest — knowing when the can is parked
The mount fires can:rest whenever the story moves between parked on an anchor
and mid-transition:
mount.addEventListener('can:rest', (e) => e.detail.atRest);The bundle uses it itself: a data-can-set switcher is only meaningful at a
rest. Mid-arrival the arrival owns the yaw outright, so the quarter-turn that
normally hides a swap never renders, and past the lock's edge-on point the lock's
own one-shot swap has already fired — a click there sticks until you scroll back
far enough to rewind it. So switchers get is-inert + aria-disabled while the
story is in flight, and their clicks are ignored. Style it yourself:
[data-can-set].is-inert { opacity: .3; pointer-events: none; }A mount event rather than another window.canflow.* entry, because canflow is
global and would be ambiguous on a page with two stories.
Continuity: fade vs travel
How the can gets from a lock into its carousel, chosen per carousel via enter:
- fade (default) — the lock dissolves (the page reveals its popout off
--reveal), then the can re-materialises at the carousel anchor. - travel — the lock does not dissolve; the can seats in the portal and flies on into the carousel as one continuous solid object (the real can is the reveal — no page popout).
The reveal handoff: --reveal and .is-locked
The bundle owns the 3D and stops at the portal. It hands the page two signals on
the portal element:
--reveal— a CSS custom property scrubbed0 → 1across the dissolve and the scroll just past it. Use this for the visual reveal. It has two phases, meeting atREVEAL_SPLIT(0.5):| range | the can | what the page does here | |---|---|---| |
0 → .5| dissolving | magnify the frame | |.5 → 1| gone | bring the popout in |The split is what lets the page sequence strictly — nothing of the popout until the can has left. A signal that was merely the dissolve's complement couldn't: it reaches 1 at the instant the can vanishes, so "after the can is gone" would be a single point with no range left to animate over. Splitting it buys that range without giving the reveal a clock, so it still reverses smoothly at any scroll speed — no page JS at all:
.wrap { /* shape both phases once, here */ --t: var(--reveal, 0); --split: .5; /* PHASE 1 — magnify, over the dissolve. cubic ease-out */ --s: clamp(0, calc(var(--t) / var(--split)), 1); --si: calc(1 - var(--s)); --scale-e: calc(1 - var(--si) * var(--si) * var(--si)); /* PHASE 2 — popout, strictly after. smoothstep */ --p: clamp(0, calc((var(--t) - var(--split)) / (1 - var(--split))), 1); --fade: calc(var(--p) * var(--p) * (3 - 2 * var(--p))); } .frame { scale: calc(1 + .5 * var(--scale-e)); transform-origin: top center; } .popout { opacity: var(--fade); }The two consumers are usually different elements, so the phases are computed on a shared ancestor and read from there.
WEBFLOW.md§5 has the full embed.Ease it in CSS, not by reshaping
--reveal. Phase one is alreadypower2.inupstream (it is the dissolve); phase two is deliberately linear so the page's own curve is the only one shaping it..is-locked— a class, added once the can has fully dissolved and held until the lock's section leaves the viewport (it clears immediately if you scrub back into the lock, since the can is returning). Use it for discrete things —pointer-events,z-index— not for the animation itself.
Driving the reveal from the class alone turns a scrubbed state into a timed
animation: scrub back through the dissolve band fast and the class flips on and
off within a frame, so the reveal plays and reverses in place. --reveal has no
clock, so it cannot whipsaw.
Model swaps
model + reset on a lock fire a setModel swap at a dead-zone section before
the arrival, so the change is unseen. Swaps are bidirectional: scrolling back
up reverts to the previous world's model, so the blue↔black boundary is clean
both ways. Swaps go through the same machinery as the hero switcher, so a running
interchange (cycle) is stopped cleanly. See "Model roster" below.
Page loop (data-can-loop)
A homepage can wrap forever: scroll past the footer and you are back at the hero.
Add data-can-loop to the mount and the page gets a forward-only wrap. It is
opt-in per mount because the Lenis constructor is shared by every page the bundle
boots on, and only the home page loops.
Bounded scroll, not Lenis infinite. Lenis ships an infinite option that
keeps an unbounded internal animatedScroll and exposes it modulo the limit —
nothing is teleported and momentum carries straight through the seam. It was
tried and abandoned, because it wraps both ways. Scroll up from the hero and
the page lands on the footer, reverse-scrubbing every transition between them in
a single tick and leaving the can on the wrong model (the intermittent "stays
blue" bug). No guard fixes it either: after a forward wrap animatedScroll is
already far past limit, so nothing distinguishes "walking backwards off the
top" from a legitimate position. Bounded scroll gets that clamp for free —
[0, limit], and scrolling up from the hero simply stops.
The wrap is therefore explicit, in main.js:
lenis.on('scroll', () => {
if (lenis.scroll >= lenis.limit - 1 && lenis.velocity >= 0) {
lenis.scrollTo(0, { immediate: true, force: true });
}
});This does dead-stop the momentum, and that is the accepted trade.
scrollTo({ immediate: true }) ends in reset(), which zeroes velocity — the
same cost the widely-posted ScrollTrigger recipe (onLeave: self =>
self.scroll(1)) pays, since it routes through the same call. Losing inertia once
per lap is a far smaller defect than a backward wrap that corrupts model state.
The consequence is that the seam has to be visually seamless, since it is not
kinematically seamless — hence the tail below.
ScrollTrigger sees the wrap as one enormous negative delta and reads it as a
violent upward scroll. Nothing here depends on its direction — every story
property is derived from progress — so that is harmless, but a new trigger with
onEnter/onLeave callbacks would need checking.
What the wrap breaks, and why rewindLocks exists. Everything in story.js
is derived from scroll except the model swaps, which latch on lock.swapped.
write() only ever runs the first unfinished transition, so a wrap from max to
0 gives the locks above it no frame at all, and they keep stale state. Three
things leak, all fixed by rewinding the whole run rather than one lock:
| leaked field | symptom |
| --- | --- |
| swapped | hero shows the footer's model after the wrap |
| swapped | next lap, that lock's arrival sees swapped already true and never re-fires — the can scrolls through the black lock still blue |
| base | next lap, the arrival reuses last lap's captured angle and the yaw snaps mid-flight |
The revert goes through spinThen, so the model change hides behind the same
quarter-turn whip as every other swap on the page. This matters most at a seam:
the wrap happens on whatever the footer was showing, so an instant revert would
pop the can a frame after the wrap — the one discontinuity a matched
footer/hero pair exists to avoid. Rewinding past the first lock has no earlier
lock to revert to; that resolves to a null key, which swap() reads as "the
model the page loaded with" (data-can-src).
rewindLocks is not loop-specific — it also fixes scrollbar drags and the Home
key, which had the same leak and were simply harder to hit.
Page requirements. Because the snap is a teleport, the loop only reads as a
loop if the last screenful is a near-mirror of the first: same anchor position,
same slant, same spin state. Yaw is already continuous (both rests advance one
idleAngle accumulator), so the model is usually the only real discontinuity —
and an idle segment may carry its own model for exactly this. A hero clone
at the end of the page then shows the base model even though its nearest lock
(black) says otherwise, swapping at the entry midpoint behind the rest's spin.
data-can-loop on a page that does not scroll is pointless but harmless: limit
is 0, so the snap targets a position the page is already at and scrollTo
returns early.
The trailing tail is required, not polish. Two independent reasons.
It keeps the final rest reachable. The last segment's range is
[triggerTop - vh, triggerBottom - vh]. Make its trigger the last element in the
document and that range ends exactly at maxScroll — so the snap, which fires a
pixel short of the limit, happens while the final transition is still a hair
under p.t === 1, and write()'s "past every transition" branch never runs. Two
symptoms, one cause: you cannot scroll to the top of the final rest, and the
model settle that lives in that branch never fires — scroll backwards over the
seam and the can keeps the hero's model.
Making the trigger taller does not help: the range end tracks its bottom, so it
moves with it. Add a sibling tail after it and make the anchor holder
position: sticky — the holder is measured live every frame, so without sticky
the can rides off the top of the screen through the tail.
It is the frame the snap fires on. Size the tail to one viewport and
scroll === limit means it exactly fills the screen. Mirror the hero there and
the teleport to 0 is invisible, which is the whole reason the lost momentum is
affordable.
┌─────────────┐ ─┐
│ .footer- │ │ entry transition plays here
│ trigger │ ─┘ ← ends at its bottom − vh
├─────────────┤ ─┐
│ tail │ │ settled rest; holder is sticky, can parks
│ (100vh) │ ─┘ ← maxScroll; the snap to 0 fires here
└─────────────┘
.footer is 200vh — .footer-trigger is its first 100vh, the tail the rest.settleLock covers the same ground defensively for rests that follow another
idle, so the model is right even if the tail is too short — but the seam still
needs the tail to look right.
Model roster & switching (data-can-models + data-can-set)
One mount can hold several models and swap between them at runtime — the hero switcher (Bullet blue / black) is this.
<div data-can data-can-src="/Blue.glb" data-can-behavior="story"
data-can-models='{
"black":{"src":"/Black.glb","facing":0},
"mule":{"src":"/Mule.glb","facing":0},
"day":{"src":"/Blue.glb","facing":0},
"night":{"cycle":["black","mule"],"everySecs":4}
}'>
...
</div>
<!-- page buttons, no JS: -->
<button data-can-set="day" class="is-active">Lock in</button>
<button data-can-set="night">Let loose</button>- Each entry is
{ src, facing }. Clicking adata-can-set="<key>"element swaps to it; the bundle toggles.is-activeon the buttons for you. - A cycle entry (
"cycle":[...], "everySecs":N) alternates between other keys, dwellingeverySecson each — the black↔mule interchange. - The whole roster is preloaded once the first model is up, so a switch is a clone, not a network wait.
The swap is a dissolve, deliberately not a crossfade: only one can is ever in
the scene, so there is no coincident-transparency sorting and no "ghost metal"
(a half-opaque can keeps full reflections and reads as glass). rig.setModel is
serialized and latest-wins: mashing the toggle settles on the last click
instead of corrupting the graph.
Multi-mount worlds (data-can-range)
A page can run several mounts, each its own full-viewport canvas; only the one
you're scrolled into should render. data-can-range="<selector>" puts a mount to
sleep whenever its range element is out of view — a dormant stage renders nothing
and its behaviour skips per-frame DOM measuring. Keep it to a few: each
mount is one WebGL context (browsers allow ~8–16). The home page deliberately
uses ONE mount instead (see story anatomy) — a mount is the expensive unit.
Webflow integration
GSAP is a peer dependency — it is NOT bundled. The page must load GSAP and
ScrollTrigger globally before can.js. Two GSAP cores can't coexist (a bundled
ScrollTrigger binds to the page's global core and version-mismatches), so the
bundle uses whatever GSAP the page already provides. Match the two versions.
- Publish the bundle to npm (below); upload the
.glbfiles to any CDN that sends CORS headers. - Site Settings → Custom Code → Footer, in this order:
(If the site already loads GSAP, just add the ScrollTrigger line — same version.)<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/ScrollTrigger.min.js"></script> <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.
JSON attributes (data-can-models, data-can-story): Webflow's attribute
field wraps the value in double quotes, so typing double-quoted JSON breaks the
HTML. Write them JS-object style instead — single quotes, unquoted keys,
trailing commas are all fine (the bundle normalizes to strict JSON before
parsing; real JSON still works too). String values must not contain quote
characters, but URLs and selectors never do:
data-can-story = [{ kind: 'idle', at: '.hero-cans' },
{ kind: 'lock', trigger: '.lock-blue', at: '.can-hole',
portal: '.portal', fit: 'contain' },]For drift/story the mount must be a fixed, full-viewport layer
(position: fixed; inset: 0; pointer-events: none; z-index: 0) so the can can
travel anywhere on screen — it is not the hole/anchor elements. Those are
separate markers the can measures; keep data-can on its own background div. For
story, the reveal after each dock (portal magnify, popout) is authored on the
page in CSS off --reveal — the bundle owns the 3D and stops there.
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. Release flow:
npm version patch # or minor / major — bumps + git-tags
git push --follow-tags
npm publish # first time: npm loginjsDelivr serves every published version immediately and immutably:
…/npm/canflow@1/dist/can.js— floats within 1.x: patches reach embedded sites automatically (~12 h lag). Use this in Webflow.…/npm/[email protected]/dist/can.js— exact pin, cached forever.
Published versions cannot be altered — a bad release is fixed by a patch, never by overwriting.
Hosting models
- CORS — the GLB is XHR-fetched from the Webflow origin, so the host must
send
Access-Control-Allow-Origin. (public/_headershas a ready Cloudflare Pages config; jsDelivr/gh sends it by default.) - New model version → new filename, so far-future caching stays safe.
- Models are not bundled — deliberately. They version independently, and 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 inmodels.jsfor zero third-party. - Build uses esbuild directly, not
vite build: Vite 8 lib mode produced a 3.3× bloated IIFE from identical source. Vite stays for dev. After any build change, smoke-test the real bundle viatest-dist.html. - The build defines
import.meta.url(see package.json): IIFE scripts have noimport.meta, and DRACOLoader constructs URLs from it — keep itsthreeversion in lockstep when upgrading.
Adding a new behaviour
Create
src/behaviors/spiral.js:import { gsap } from '../core/gsap.js'; 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 writer
per property; time-based loops use ease: 'none'; with scrub, duration
means relative scroll distance. When a property has two sources (e.g. idle spin
- scroll on
can.rotation.y), integrate ongsap.tickerand compose them in one writer — GSAP only scrubs scalars. Seedrift.js/story.js.
Asset pipeline
Two files per can: a model (GLB) and a poster (still shown while the model loads). Both want compression — models are the bandwidth cost, posters the first-paint cost.
Models — always optimize before shipping
npx @gltf-transform/cli optimize in.glb out.glb --compress draco --texture-compress webpMeasured on the Bullet cans: 2.5–3 MB → 330–410 KB (~7.5×) with no visible
quality loss. --texture-compress webp is the big win (textures are ~95 % of a
clean GLB); --compress draco handles geometry (the loader decodes it). Always
eyeball the result in ambient.html — watch for WebP banding on smooth/white
areas; add --texture-compress-quality 90 if it shows.
Posters — the loading placeholder
- Provide an upright still, ideally on transparent background; the runtime
leans it to match
data-can-slant— don't bake the tilt in. - Convert to WebP (
~400 KB PNG → ~80 KB); it's the first byte a visitor sees. - The cleanest match renders the poster from the dark scene itself (same env, slant, angle) so it equals the first 3D frame and the cross-fade is invisible.
Per-model setup
- Point
data-can-srcat the model,data-can-posterat the still. - Measure facing: add
data-can-debug, nudge__can.rig.can.rotation.yuntil the label faces the camera; setdata-can-facing= 180 + that offset (or fix the origin in Blender and leave it at 0/180). - Steel materials: metalness ≈ 1, roughness ≈ 0.2; the env map does the rest.
Gotchas
- Two Lenis instances break scrolling. If the site already runs Lenis, set
data-can-lenis="off"on a mount (it disables Lenis page-wide). prefers-reduced-motionis respected: idle loops are skipped (static slant still applies); scroll-driven animation stays, since the user drives it.- Each mount = one WebGL context; browsers allow ~8–16. The home page uses one mount for the whole story precisely to avoid spending several.
- The stage self-heals its size every frame — it reconciles renderer +
camera against the mount's own size in the render loop, because a
windowresize listener misses pinch-zoom, mobile URL-bar collapse, and DPR changes. A stalecamera.aspectmaps the can hundreds of px off its anchor. - Frame order on
gsap.tickeris load-bearing:lenis.raf→ the stage's size self-heal → the behavior'swrite()→ the render. Listeners fire in the order they were added, so the stage deliberately does not register its render at construction —main.jscallsstage.startRender()after the behavior has added its callback. Registered earlier, every frame would draw the transform computed on the previous one: a free frame of latency, visible as the can lagging its anchor while scrolling. - Anchors are measured on the main thread, so scroll must be too. The canvas
is fixed and composited; the can's place inside it comes from a live
getBoundingClientRect. Any scrolling the main thread doesn't see in the same frame shows up as the can sliding off its anchor. That is why Lenis runs withsyncTouch: true— native touch scroll is asynchronous and would leave the can 1–3 frames behind through a flick. See MOBILE-PLAN 2c before changing it. - A hidden tab / unfocused preview pane suspends rAF, freezing
gsap.ticker— desirable in production, but automated tests then show a frozen/stale can. Verify numerically (forcegsap.ticker.tick()and read state) or in a real browser. window.__canonly exists withdata-can-debug; remove it in production.
Status
Working (verified in-browser): ambient, wiggle, drift (live on the client); story — the single-mount home sequence (hero + switcher → blue lock → blue carousel → swap → black lock → black carousel), path-independent and seam-free on reverse; model roster + dissolve swap (serialized, preload, cache); esbuild build.
