PROJECTSVISIT LIVE SITE
CASE STUDY — 2024

WATTWILLER

CLIENTAgency client — Izhak Interact
ROLEFront-end & motion architecture
YEAR2024
STACKWeb Components · GSAP · ScrollTrigger · Lenis · Taxi.js
WATTWILLER
01 — The problem

A WordPress theme built from 32 different PHP templates needed consistent scroll and entrance animation across all of them, without every template author hand-wiring GSAP timelines and manually cleaning up listeners and ScrollTrigger instances on every page — and it needed to work inside SPA-like page transitions (Taxi.js swaps the `<body>` content instead of doing a hard reload), which raises the stakes on cleanup considerably.

The usual failure mode at this scale is not that the animations look wrong — it is that leftover listeners and ScrollTrigger instances from a page you already navigated away from keep running, pointing at DOM nodes that no longer exist. Without a lifecycle mechanism, every internal navigation would leak one more running animation loop, and the site would slow down the longer someone stayed on it.

Who this was for

Other front-end contributors on the same codebase, who needed to drop animation into a new template without becoming an expert in the cleanup discipline every scroll-triggered animation requires.

02 — Decisions made — and why
01

Built the animation layer as native custom elements (`<c-title>`, `<c-paragraph>`, `<c-desc>`, `<c-content>`, `<c-media>`, `<c-translation>`, `<c-medias>`, `<c-faq>`, `<c-description>`, `<c-path>` — 10 files, via the `piecesjs` micro-library) rather than a shared JS utility function.

A custom element has a defined lifecycle — `mount()`/`unmount()` mapping to the native `connectedCallback`/`disconnectedCallback` — that a plain utility function does not. Wrapping existing HTML in a tag (`<h2><c-title>…</c-title></h2>`) means the browser itself tells you exactly when to arm the animation and when to tear it down, so a template author enables animation by wrapping markup, not by importing and manually invoking anything.

02

Every element kills its own GSAP tweens and ScrollTrigger instances on `unmount()` — no exceptions — which is what makes Taxi.js page transitions without a full reload viable at all.

Without this contract, navigating between templates without a hard reload would leak a running ScrollTrigger per page visited, each still pointing at a detached DOM node — a real memory and performance cost that would only surface after several navigations, hard to catch by testing one page at a time. Site.js destroys and recreates the shared `Partials` controller on every `NAVIGATE_END`, and each Web Component independently cleans up at its own disconnection — no coordination between them is needed.

03

A single centralized `requestAnimationFrame` loop (`Site.js`) drives both Lenis smooth-scroll and `ScrollTrigger.update()`, bridged through `ScrollTrigger.scrollerProxy` since Lenis replaces native scroll.

ScrollTrigger expects `window.scrollY` to advance natively; Lenis instead tracks its own virtual scroll position, so `scrollerProxy` hands ScrollTrigger a function that returns Lenis's `store.smoothScroll.scroll` value in place of the native one. Rather than each of the 90+ component instances running its own RAF loop, one shared loop advances Lenis then tells ScrollTrigger to re-evaluate every active trigger — the cost scales with the number of animated sections on a page, but each frame does the minimum shared work once instead of duplicating it per component.

04

Shipped as a small set of reusable components, adopted 90+ times across 32 templates — and reserved GSAP for cases where its sequencing engine earns its cost, using plain CSS transitions for binary hover/menu states instead.

The point of building this as a component library rather than a one-off effect was reuse at scale — a template author drops in the element and gets the teardown contract for free. But the header show/hide-on-scroll and the mobile menu use CSS `transition` on `transform` driven by JS class toggles, not GSAP: a two-state animation with no scroll-scrub or complex sequencing does not need a sequencing engine, and shared `cubic-bezier` custom properties keep the CSS transitions visually consistent with the GSAP-driven ones without duplicating any easing logic.

GALLERY
WATTWILLER — 01
WATTWILLER — 02
WATTWILLER — 03
03 — What I chose not to do

No central animation registry or global timeline manager.

A central manager would need every template to register and unregister correctly with it — reintroducing the exact coordination problem the per-element teardown contract was built to avoid. Keeping teardown local to each element means there is nothing shared to get out of sync.

Did not apply `prefers-reduced-motion` consistently — the parallax components (`<c-media>`, `<c-translation>`) respect it, the text-reveal components (`<c-title>`, `<c-paragraph>`, `<c-content>`, `<c-desc>`) and the SVG path-draw (`<c-path>`) do not.

The parallax effects were treated as the most likely to cause vestibular discomfort, so they got the guard first under time pressure; the text-reveal components were judged lower-risk and left unguarded. That reasoning does not actually hold — a visitor with reduced-motion enabled still gets flying letter-by-letter text on every heading — and it is an inconsistency I would not defend, just one I can point at precisely.

04 — What I'd measure
90+USES ACROSS THE SITE
32TEMPLATES ADOPTING IT
10DISTINCT C-* COMPONENTS SHIPPED

The number I'd want to track in production is listener/ScrollTrigger instance count after navigating through several templates in a row without a reload — the direct test of whether the teardown contract actually holds at scale, not just in the templates it was first tested against. `page-transition.js` also has a documented, unused `destroy()` method — never called from anywhere in the codebase — which is the kind of code-smell a leak-count metric would immediately make relevant or irrelevant.

05 — What I'd redo differently

I'd write an automated test that mounts and unmounts every template in a loop and asserts the ScrollTrigger/listener count returns to zero each time — today that contract is enforced by convention and code review, not by a test that would catch a regression automatically the next time someone adds a template. I'd also fix the header's scroll-direction listener, which reads native `window.scrollY` directly instead of the Lenis-driven `store.currentScroll` the rest of the codebase treats as the source of truth — it happens to work today because Lenis lets native scroll advance in parallel, but it is the one place that quietly ignores the project's own convention.

NEXT PROJECT
ASKAR
KEEP SCROLLING