npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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 GLB

Where 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). See models.js.
  • Every can is normalized to 1.5 world units, so camera framing and hole-fitting math hold for any asset.
  • facing is per-asset — a clean Blender export usually needs 0; a scan rarely centres its label at 180°. Measure once (see "Per-model setup").
  • Materials render FrontSide (forced in models.js). The cans export DoubleSide, 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-slant leans 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 TAU multiple), landing at a fixed landed.
  • Each carousel HOLDS its lock's landed front 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 with spin: 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 scrubbed 0 → 1 across the dissolve and the scroll just past it. Use this for the visual reveal. It has two phases, meeting at REVEAL_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 already power2.in upstream (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 a data-can-set="<key>" element swaps to it; the bundle toggles .is-active on the buttons for you.
  • A cycle entry ("cycle":[...], "everySecs":N) alternates between other keys, dwelling everySecs on 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.

  1. Publish the bundle to npm (below); upload the .glb files to any CDN that sends CORS headers.
  2. Site Settings → Custom Code → Footer, in this order:
    <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>
    (If the site already loads GSAP, just add the ScrollTrigger line — same version.)
  3. 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 login

jsDelivr 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/_headers has 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 into public/ and change the path in models.js for 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 via test-dist.html.
  • The build defines import.meta.url (see package.json): IIFE scripts have no import.meta, and DRACOLoader constructs URLs from it — keep its three version in lockstep when upgrading.

Adding a new behaviour

  1. 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, { /* ... */ });
    }
  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 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 on gsap.ticker and compose them in one writer — GSAP only scrubs scalars. See drift.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 webp

Measured 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

  1. Point data-can-src at the model, data-can-poster at the still.
  2. Measure facing: add data-can-debug, nudge __can.rig.can.rotation.y until the label faces the camera; set data-can-facing = 180 + that offset (or fix the origin in Blender and leave it at 0/180).
  3. 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-motion is 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 window resize listener misses pinch-zoom, mobile URL-bar collapse, and DPR changes. A stale camera.aspect maps the can hundreds of px off its anchor.
  • Frame order on gsap.ticker is load-bearing: lenis.raf → the stage's size self-heal → the behavior's write() → the render. Listeners fire in the order they were added, so the stage deliberately does not register its render at construction — main.js calls stage.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 with syncTouch: 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 (force gsap.ticker.tick() and read state) or in a real browser.
  • window.__can only exists with data-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.