Trionn was constructed as an exploration of how far a studio web site might be pushed when animation, WebGL, and interplay methods are handled as one unified expertise. After months of experimentation and iteration, the ultimate end result combines GSAP, Three.js, Lenis, and customized Internet Audio interactions right into a responsive digital expertise the place each part is pushed by its personal rigorously crafted system.
The undertaking developed via a number of ideas earlier than reaching its closing route. The interactive hero, scroll-driven storytelling, procedural graphics, and real-time results weren’t deliberate as remoted options, however steadily developed right into a linked animation framework.
Constructed over 4 months, the positioning acquired recognition from FWA, GSAP, Orpetron, CSS Design Awards, Internet Design Awards, CSS Winner, and a number of other worldwide design galleries. Extra importantly, the method revealed a sequence of technical challenges round efficiency, synchronization, rendering, and interplay design.
On this case examine, we’ll discover the structure behind Trionn, breaking down the animation methods, WebGL strategies, optimization methods, and code patterns used to deliver the expertise to life.
Technical Overview
Constructing a web site with this degree of animation meant balancing inventive flexibility with efficiency. Every a part of the stack has a transparent duty, from driving animations and scroll interactions to rendering WebGL scenes and producing audio in actual time.
The core applied sciences used all through the undertaking are:
- GSAP + @gsap/react for timelines, web page transitions, and component-level animations.
- ScrollTrigger for scroll-driven reveals, pinned sections, and scrubbed sequences.
- SplitText for reusable character-, word-, and line-based textual content animations.
- Three.js for the hero image, the Companies part, and the interactive work grid.
- Lenis for clean scrolling, synchronized with GSAP.
- Internet Audio API for producing interactive sound results at runtime.
- Subsequent.js and React as the applying framework.
- Tailwind CSS for styling.
- Swiper for the testimonials and awards carousels.
GSAP sits on the middle of the animation system. Web page transitions, scroll-driven sequences, pinned sections, and component-level animations are all constructed round GSAP timelines and managed with useGSAP, which simplifies setup and cleanup as parts mount and unmount in Subsequent.js.
ScrollTrigger handles a lot of the web site’s scroll interactions, from pinned storytelling sections to scrubbed animations and reveal results. We additionally rely closely on gsap.matchMedia() so desktop and cellular layouts can have their very own animation logic as a substitute of sharing the identical values.
For textual content animation, we constructed a reusable BlurTextReveal element utilizing SplitText. It helps character-, word-, and line-based animations whereas centralizing reduced-motion dealing with, GPU layer cleanup, and ScrollTrigger refreshes as a substitute of fixing these issues for each particular person heading.
Three.js powers the positioning’s customized WebGL experiences. We selected to not use React Three Fiber as a result of we wished direct management over the shared render loop, useful resource administration, and the hero image’s individually animated mesh panels.
Lenis is pushed instantly from gsap.ticker, preserving scrolling synchronized with ScrollTrigger all through the positioning.
Interactive sound results—together with the hero hover, blast, and weld results—are generated at runtime with the Internet Audio API as a substitute of utilizing prerecorded audio information.
The Hero Part
The hero developed significantly over the course of the undertaking. It combines WebGL, GSAP, SplitText, and the Internet Audio API right into a single interplay system, making it one of the technically concerned components of the positioning.
The hero is constructed from two layers that share the identical state. The background is a single Three.js scene (useTrionnSymbolScene.ts) chargeable for the model image, together with its idle movement, magnetic hover, hold-to-blast interplay, and weld spark results. These interactions all contribute to a single explodeAmt worth, which controls how far the image’s panels separate. Whether or not the person scrolls, hovers, or holds the mouse button, every interplay updates the identical worth, permitting transitions between states to really feel clean and steady.
The foreground consists of normal DOM parts—the headline, rotating phrase, and stats trace—animated with GSAP and SplitText. Utilizing common HTML retains the content material accessible whereas mix-blend-mode: distinction ensures it stays readable over the WebGL canvas.
Each layers are synchronized via a shared transitionReady flag. Animations don’t start till the web page transition has completed, with non-critical work deferred utilizing requestIdleCallback to keep away from competing with the preliminary web page load.
Hero Headline Reveal
On web page load, the hero headline (“Designed to”) animates into view one character at a time utilizing a staggered blur-to-sharp transition. As an alternative of a easy fade-in, every character steadily comes into focus, making a extra dynamic introduction to the web page.
// parts/Sections/House/Banner.tsx — utilization
// parts/TextAnimation/BlurTextReveal.tsx — the engine behind it
const break up = new SplitText(textRef.present, {
sort: "chars, phrases, traces",
smartWrap: true,
});
const targets = break up.chars; // animationType === "chars"
gsap.set([textRef.current, targets], {
autoAlpha: 0,
filter: "blur(12px)",
willChange: "filter, opacity", // promote to its personal GPU layer solely whereas animating
});
const tl = gsap.timeline({
paused: guide,
});
tl.to(textRef.present, {
autoAlpha: 1,
filter: "blur(0px)",
length: 0.5,
}, delay)
.to(targets, {
autoAlpha: 1,
filter: "blur(0px)",
length: 0.8,
stagger: {
every: 0.08,
from: "random",
}, // characters settle out of order
ease: "power2.out",
}, delay);
Utilizing filter: blur() alongside opacity creates the impression that the textual content is coming into focus, quite than merely fading in. As soon as the animation completes, will-change is eliminated so the textual content not occupies its personal GPU layer. The identical BlurTextReveal element is reused all through the positioning for the rotating phrase and the stats trace, with completely different animation settings.
Hero Image: Idle State
When idle, the hero image rotates constantly whereas every of its three arms follows a refined sine-wave movement with an unbiased part offset. This prevents the animation from feeling completely synchronized and offers the image a extra natural sense of motion.
// hooks/useTrionnSymbolScene.ts — per-frame replace loop
// Auto-rotate: a relentless rotational drift, eased towards the mouse place
if (!st.dragging) {
st.rotY += prefersReducedMotion ? 0.0015 : 0.0042; // base spin pace
st.rotX = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, st.rotX));
group.rotation.x +=
(st.rotX + mouse.y * 0.22 - group.rotation.x) * 0.06; // eased lerp
group.rotation.y +=
(st.rotY + mouse.x * 0.22 - group.rotation.y) * 0.06;
}
// Per-panel ambient drift — every of the three arms will get its personal part offset
// so the entire image does not breathe in lockstep
particles.forEach((p) => {
const part = p.shapeIdx * (Math.PI * 2 / 3); // 0°, 120°, 240°
const armDriftX =
Math.sin(t * 0.4 + part) * 0.012 * (1 - explodeAmt);
const armDriftY =
Math.cos(t * 0.35 + part) * 0.008 * (1 - explodeAmt);
const armDriftZ =
Math.sin(t * 0.3 + part * 1.5) * 0.006 * (1 - explodeAmt);
// ...place += drift, scaled all the way down to 0 the second any explode/hover state kicks in
});
When prefersReducedMotion is enabled, the rotation pace is diminished as a substitute of being disabled utterly. Mouse motion is utilized via linear interpolation (lerp), giving the image a clean, magnetic really feel quite than instantly matching the cursor. The ambient drift can also be scaled by (1 - explodeAmt), permitting it to fade out naturally as different interplay states take over.
Hero Image: Magnetic Hover
Because the cursor strikes over the image, the panel beneath it briefly “costs,” turning into brighter and extra reflective, whereas a brief beep performs the primary time the cursor enters that panel. Hover detection is carried out with raycasting as a substitute of CSS, permitting the interplay to comply with the image’s precise 3D geometry because it rotates.
// hooks/useTrionnSymbolScene.ts — hover detection by way of raycasting
const raycaster = new THREE.Raycaster();
// Per body: solely test for hover when the image is absolutely assembled
// (not mid-explode, not scrolled away, not within the intro animation)
if (
st.mouseScreenX !== -9999 &&
st.scrollProgress < 0.08 &&
st.clickBurst < 0.05 &&
st.introAmt < 0.08
) {
raycaster.setFromCamera(mouse, digicam);
const hits = raycaster.intersectObjects(
particles
.filter((p) => !p.isEdge)
.map((p) => p.mesh as THREE.Mesh),
false,
);
const nowHit = hits.size > 0 ? hits[0].object : null;
if (nowHit !== st.hoveredMesh) {
if (nowHit) {
const hm = nowHit as THREE.Mesh & {
_flash?: quantity;
_flashActive?: boolean;
};
hm._flash = 1.0; // triggers the charge-up under
hm._flashActive = true;
audio.playHoverBeep(); // solely fires on a brand new panel, not each body
}
st.hoveredMesh = nowHit;
}
}
// Elsewhere: decay the flash and ramp the fabric towards its "charged" look
mesh._flash = (mesh._flash || 0) * 0.92; // exponential decay every body
const f = mesh._flash;
mat.envMapIntensity = 3.0 + f * 1.6; // brighter reflections
mat.clearcoatRoughness = Math.max(0.01, 0.05 - f * 0.035);
mat.transmission = 0.35 + f * 0.32; // extra "glassy"
Raycasting in opposition to the image’s geometry ensures the hover impact follows its precise form and rotation. Every panel’s spotlight decays independently utilizing a easy exponential decay, avoiding the overhead of making a separate GSAP tween for each mesh.
Hero Strains: Weld Spark Impact
Three information traces animate outwards from the image when the web page hundreds. As soon as the animation is full, hovering over any of the traces triggers a brief burst of weld-like sparks that arc towards one or two of the remaining traces, reinforcing the hero’s immediate: “Dare ⚡ to the touch the traces.”
// hooks/useTrionnSymbolScene.ts
// Sparks are solely enabled as soon as the information traces have completed drawing
const baseLinesReadyForSpark =
inS1 &&
undrawAmt < 0.02 &&
st.lineState.each((s) => s.prog >= 0.995);
if (baseLinesReadyForSpark) {
// Hit-test the mouse in opposition to all 3 line paths (14px tolerance)
const allLinePts = [ptsL, ptsR, ptsB];
let hitResult: { x: quantity; y: quantity } | null = null;
let hitLineIdx = -1;
for (let li = 0; li < allLinePts.size; li++) {
const h = mouseNearLine(allLinePts[li], 14);
if (h) {
hitResult = h;
hitLineIdx = li;
break;
}
}
if (hitResult !== null) {
// New hover onto a line (not a steady maintain) → arm a brief burst
if (!st.sparkHoverActive && st.sparkWasAway) {
st.sparkHoverActive = true;
st.sparkBurstLeft = 5 + Math.ground(Math.random() * 2); // 5–6 bolts per hover
st.sparkWasAway = false;
}
if (st.weldCooldown <= 0 && st.sparkBurstLeft > 0) {
const wp = unproj2(hitResult.x, hitResult.y); // display → world area
// Decide 1–2 different traces as targets
const otherIdxs = [0, 1, 2].filter((i) => i !== hitLineIdx);
const rely = Math.random() > 0.5 ? 1 : 2;
const targetIdxs = otherIdxs
.type(() => Math.random() - 0.5)
.slice(0, rely);
const nearWpts = targetIdxs.map((li) => {
// Discover the closest level on the goal line to the hit place
const pts = allLinePts[li];
let bestPt: LinePt | null = null;
let bestD = Infinity;
for (const pt of pts) {
const dd =
(pt.x - hitResult!.x) ** 2 +
(pt.y - hitResult!.y) ** 2;
if (dd < bestD) {
bestD = dd;
bestPt = pt;
}
}
return unproj2(bestPt!.x, bestPt!.y);
});
triggerWeld(wp, nearWpts, !st.sparkSoundPlayed);
st.sparkBurstLeft--;
st.weldCooldown = 0.04 + Math.random() * 0.06; // throttle between bolts
}
}
}
A “prepared” test ensures the weld impact is just enabled as soon as all three information traces have completed drawing. The sparkWasAway flag triggers a brand new burst solely when the cursor enters a line, stopping a steady stream of sparks whereas hovering. Every burst varies barely, with a random variety of bolts and randomly chosen goal traces, so the interplay by no means performs out precisely the identical means twice.
Bolt technology can also be rate-limited utilizing weldCooldown, which areas every bolt 0.04–0.10 seconds aside no matter body price. The glow impact is constructed by layering THREE.Line geometries, offering the specified look with out the price of a post-processing bloom go.
The information traces themselves are rendered to an offscreen 2D , which is then used as a texture within the Three.js scene. This enables the weld impact to carry out light-weight 2D hit testing in opposition to canvas-space coordinates as a substitute of raycasting in opposition to 3D geometry. As a result of the impact behaves extra like a particle system than a UI animation, its timing is pushed by easy counters as a substitute of GSAP timelines. A spark sound, synthesized with the Internet Audio API, performs as soon as per burst quite than as soon as per bolt to forestall overlapping audio.
Hero Image: Maintain-to-Blast
Clicking and holding the hero image triggers a multi-stage interplay. Close by interface parts, together with the navigation and headings, start to vibrate because the cost builds. After roughly half a second, the image breaks aside into its particular person panels, every following its personal trajectory and rotation whereas an explosion and sustained “whoosh” sound play. Releasing the mouse reverses the sequence, easily bringing the image again collectively.
Press Down: Begin the Cost-Up
When the person presses the image, the interplay enters a charging state by resetting the timer and activating the preliminary vibration suggestions earlier than the blast sequence begins.
const onMouseDown = (e: MouseEvent) => {
// ...hit-test guard omitted...
st.holding = true;
st.holdTime = 0;
st.vibrateAmt = 1.0;
st.vibratePhase = 0;
st.clickBurst = 0;
st.joinPlayed = false;
};
window.addEventListener("mousedown", onMouseDown);
Cost-Up and Blast
Whereas the mouse button is held, the interplay progresses via two phases. The primary 0.5 seconds are devoted to the charge-up animation. As soon as that threshold is reached, the image breaks aside into its particular person panels, transitioning into the blast sequence.
if (st.holding) {
st.holdTime += 1 / 60;
st.vibrateAmt = 1.0;
if (st.holdTime < 0.5) {
st.clickBurst = 0; // nonetheless charging
} else {
if (st.clickBurst === 0) {
// first body previous the edge
audio.stopVibrateSound();
audio.playExplodeSound();
audio.startWooshSound();
}
st.vibrateAmt *= 0.88;
st.clickBurst = Math.min(1.0, st.clickBurst + 0.02); // ramps from 0 → 1 over ~50 frames
}
} else {
// Launched — each values ease again down as a substitute of snapping to 0
st.vibrateAmt = Math.max(0, st.vibrateAmt - 0.08);
st.clickBurst = Math.max(0, st.clickBurst - 0.025);
}
clickBurst Drives the Explosion
The clickBurst worth controls how far every panel strikes from its authentic place. Because it will increase from 0 to 1, each panel follows its personal predefined route and rotation, creating the impact of the image breaking up whereas remaining absolutely deterministic.
const burstContrib =
st.scrollProgress < 0.15 ? st.clickBurst : 0;
const explodeAmt = Math.max(
st.scrollProgress,
st.hoverAmt,
burstContrib,
st.introAmt,
);
particles.forEach((p) => {
const amt = Math.max(0, explodeAmt - p.delay); // staggered by every panel's delay
const burst = amt * 5.5;
p.mesh.place.set(
p.explodeDir.x * burst + /* ...idle drift, mouse offset... */ 0,
p.explodeDir.y * burst,
p.explodeDir.z * burst,
);
p.mesh.rotation.x =
p.spinAxis.x * p.spinSpeed * amt * Math.PI;
});
Close by UI Parts React to the Cost
Whereas the image costs, close by interface parts—together with the navigation and headings—use the identical shared state so as to add a refined vibration impact. When the interplay ends, they return easily to their resting place utilizing CSS transitions.
vibrateEls.forEach((el) => {
el.fashion.transition = "none";
el.fashion.rework = `translate(${sx}px, ${sy}px)`; // sx/sy from a sine wave
});
// On launch:
el.fashion.transition =
"rework 0.7s cubic-bezier(0.25, 0.46, 0.45, 0.94)";
el.fashion.rework =
"perspective(600px) translate(0px, 0px) rotateX(0deg)";
The 0.5-second charge-up introduces a deliberate delay earlier than the explosion, making the interplay really feel intentional quite than instantaneous. A single explodeAmt worth combines the results of scrolling, hovering, and the hold-to-blast interplay utilizing Math.max(), permitting each state to share the identical animation logic. As a result of the interplay is pushed by state values as a substitute of tweens, releasing the mouse at any level easily reverses the impact with out requiring a separate animation path.
Companies Scroll Sequence
The Companies part is essentially the most advanced scroll-driven sequence on the positioning. A single shared scrollProgressRef worth (0–1) coordinates each a part of the expertise: scrubbing via a 371-frame WebP picture sequence, breaking the “OUR SERVICES” headline into animated glyph particles, revealing the six service playing cards alongside predefined movement paths, transitioning the positioning’s colour palette from black to white, and ending with a stripe wipe into the Testimonials part.
A Shared Scroll Driver
Your complete Companies sequence is pushed by a single normalized scrollProgressRef worth starting from 0 to 1. Relatively than creating separate timelines for every animation, particular person progress ranges are derived from this shared worth to manage the picture sequence, headline animation, service playing cards, colour transitions, and part outro. This method retains each a part of the sequence synchronized whereas making it simpler to regulate the timing of particular person results.
// parts/Sections/House/TrionnServices.tsx
const TOTAL = 371;
const EXPLODE_START = 0.35;
const EXPLODE_END = 0.53;
const CARDS_START = 0.56;
const CARDS_END = 1.0;
// Contained in the RAF loop:
const linear = scrollProgressRef.present; // 0 → 1, owned by the dad or mum bridge
s.scrollT = mapServicesScrollProgress(linear, isMobile); // remapped for this part
const targetFrame = s.scrollT * (TOTAL - 1);
s.videoIdx += (targetFrame - s.videoIdx) * 0.12; // ease towards the goal body
drawFrame(s.videoIdx); // updates the
supply
const inZone =
s.scrollT >= EXPLODE_START &&
s.scrollT <= EXPLODE_END;
const explodeT = inZone
? (s.scrollT - EXPLODE_START) / (EXPLODE_END - EXPLODE_START)
: 0;
if (inZone && s.gsapTL) {
s.gsapTL.progress(explodeT);
}
updateCards(s.cardsT); // playing cards use their very own smoothed copy of scrollT
The Picture Sequence
The background animation is a sequence of 371 WebP frames displayed by updating the src of a normal component. As an alternative of rendering video or utilizing a , this method retains the implementation light-weight whereas nonetheless permitting the animation to be scrubbed instantly by the scroll place.
// drawFrame — replace a single
as a substitute of utilizing
Headline Particle Explosion
The “OUR SERVICES” headline is break up into particular person glyphs, every measured and animated independently. Because the scroll reaches the transition level, each glyph follows its personal trajectory, creating the impact of the textual content breaking up earlier than the service playing cards are launched.
// Measure every character's on-screen place utilizing the Vary API.
// This matches the rendered structure, together with kerning and line wrapping.
const measureChars = useCallback(() => {
overlay.querySelectorAll("[data-line]").forEach((line) => {
// ...
for (let i = 0; i < uncooked.size; i++) {
vary.setStart(textNode, i);
vary.setEnd(textNode, i + 1);
const r = vary.getBoundingClientRect();
outcomes.push({
ch: show[i],
x: r.left + r.width / 2,
y: r.high + r.top / 2,
/* font props */
});
}
});
return outcomes;
}, []);
// Every measured character turns into its personal , preserving the unique
// typography earlier than being animated alongside a person trajectory.
m.forEach((p, i) => {
const isHero = hello.has(i);
const angle = rand(-Math.PI, Math.PI);
const pace = isHero
? rand(0.05, 0.15) * maxDim
: rand(0.4, 0.9) * maxDim;
s.particles.push({
el,
ox: p.x,
oy: p.y,
dirX: Math.cos(angle),
dirY: Math.sin(angle) * rand(-1.0, 0.18),
pace,
/* ... */
});
});
Service Card Animation
Because the headline particles disperse, the six service playing cards animate into view alongside predefined curved paths. On desktop, the playing cards are launched in left and proper pairs, making a balanced composition whereas preserving the scroll sequence straightforward to comply with.
// Desktop: every pair begins 0.2 timeline models aside and follows a curved X path
const arc =
frac <= 0.5 ? Math.sin(frac * Math.PI) : 1;
const lX = lStartX + arc * (lPeakX - lStartX);
const lY = lStartY + frac * (lEndY - lStartY); // Y strikes linearly from backside to high
frames.push({
x: lX,
y: lY,
opacity: op,
});
// As soon as a pair reaches its middle level, animate the SVG icon stroke
if (!s.svgFired.has(lk) && tlTime >= centerTime) {
s.svgFired.add(lk);
gsap.fromTo(
paths,
{
drawSVG: "0%",
},
{
drawSVG: "100%",
length: 1.5,
stagger: 0.04,
},
);
}
Stripe Wipe Transition
The part ends with a stripe wipe transition that’s reused all through the positioning, together with the Imaginative and prescient and About sections. Utilizing the identical transition sample throughout a number of sections helps keep visible consistency whereas preserving the implementation centralized and reusable.
// `applyStripeHold` scrubs a paused stripe reveal timeline over the ultimate
// portion of the part's scroll vary, then slides the Testimonials part
// into view utilizing a GPU-accelerated `yPercent` rework.
const holdT = Math.max(
0,
Math.min(1, (linear - holdStart) / (1 - holdStart)),
);
cache.tl.progress(holdT);
Not like a lot of the web site, this part doesn’t use ScrollTrigger. Each animation is derived from a single scroll progress worth that’s recalculated every body, preserving your entire sequence synchronized with out coordinating a number of timelines.
To keep away from blocking the preliminary web page load, the 371 WebP frames are preloaded in requestIdleCallback batches of 20, with img.decode() used to arrange every body earlier than it’s displayed. The service card movement follows a distinct method: the GSAP timeline is constructed as soon as at any time when the structure adjustments after which scrubbed by way of .progress() throughout scrolling, avoiding the price of recalculating every card’s trajectory each body.
Dribbble Helix Gallery
The Double Helix Gallery combines DOM parts and WebGL to create a scroll-driven 3D sequence. 9 playing cards are organized alongside a parametric helix that rotates previous the digicam because the person scrolls, whereas two animated information traces hint the construction. Playing cards reply to hover via raycasting, and the sequence concludes with the helix unfolding right into a flat grid, full with rounded-corner masking and a decaying ripple impact.
Constructing the Helix
The gallery structure is generated completely with parametric equations quite than a prebuilt 3D mannequin. Every card’s place and orientation are calculated from its place alongside the helix, making your entire construction procedural and straightforward to adapt because the person scrolls.
// parts/DribbleSection.tsx
const dip = (a: quantity) => {
const d = (a - MID) / DIP_S;
return DIP_A * Math.exp(-d * d); // Gaussian dip on the midpoint
};
const hPos = (a: quantity) =>
new THREE.Vector3(
R * Math.cos(a),
Y_START + a * pitchPerRad - dip(a), // rises steadily with a refined midpoint dip
R * Math.sin(a),
);
Bending Playing cards onto the Helix
Relatively than positioning particular person meshes across the helix, every card’s geometry is deformed instantly so it naturally follows the curve. By rewriting the vertex positions, each card bends to match the form of the helix whereas remaining a single mesh, producing a way more convincing end result than merely rotating flat planes.
// wrapCardOnHelix — runs as soon as per seen card, per body
for (let col = 0; col < C; col++) {
const angle =
(sArcStart + (col / W_SEGS) * sArcWidth) / dsPerRad;
// Place every column alongside the helix utilizing inline scalar math.
// Avoiding Vector3 allocations retains the render loop freed from GC stress.
for (let row = 0; row < 2; row++) {
pos.setXYZ(
row * C + col,
cpX + ux * offsetAmt,
cpY + uy * offsetAmt,
cpZ + uz * offsetAmt,
);
}
}
pos.needsUpdate = true;
Scroll-Pushed Rendering
The helix isn’t rendered in a steady animation loop. As an alternative, rendering is pushed instantly by scroll place, with a light-weight ticker solely working when wanted to let interactions and transitions settle easily. This retains the scene responsive whereas avoiding pointless work when the helix is at relaxation.
const st = ScrollTrigger.create({
set off: part,
begin: "high high",
finish: `+=${totalScroll}`,
pin: true,
onUpdate: () => {
renderTick(); // render instantly on each scroll replace
syncTicker?.(); // decide whether or not the idle ticker ought to stay energetic
},
});
const isActive = () => {
const margin = window.innerHeight; // one viewport earlier than and after the part
const viewTop = window.scrollY - margin;
const viewBottom = window.scrollY + window.innerHeight + margin;
return (
viewBottom > st.begin &&
viewTop < st.begin + totalScroll
);
};
syncTicker = () => {
isActive() ? startTicker() : stopTicker();
};
Raycast-Based mostly Hover Interplay
Playing cards reply to hover utilizing Three.js raycasting quite than DOM occasions. As an alternative of immediately altering measurement, every card easily eases towards a goal scale, making the interplay really feel extra pure and preserving the fluid movement of the helix.
if (pointerActive) {
raycaster.setFromCamera(pointer, cam);
const hits = raycaster.intersectObjects(
playing cards.filter((c) => c.seen),
false,
);
if (hits.size > 0) {
hoveredCard = hits[0].object as THREE.Mesh;
}
}
for (let okay = 0; okay < N; okay++) {
const cur = playing cards[k].userData.hoverScale ?? 1.0;
const goal = playing cards[k] === hoveredCard ? 1.12 : 1.0;
// Ease towards the goal scale as a substitute of snapping immediately
playing cards[k].userData.hoverScale =
cur + (goal - cur) * hoverLerpK;
}
Rounded Corners with a Fragment Shader
Relatively than counting on clear PNGs or nine-slice property, every card makes use of a light-weight fragment shader to generate rounded corners procedurally. A signed-distance operate masks the picture within the shader, producing crisp edges at any measurement whereas preserving the geometry easy and the rendering environment friendly.
// Fragment shader — rounded corners for the gallery playing cards
vec2 q = abs(pxPos) - halfSize + uRadius;
float dist =
min(max(q.x, q.y), 0.0) +
size(max(q, 0.0)) -
uRadius;
float alpha = 1.0 - smoothstep(-0.5, 0.5, dist);
if (alpha <= 0.0) {
discard;
}
gl_FragColor = vec4(texture2D(map, vUv).rgb, alpha);
Warming Up the Scene
To stop a noticeable hitch when the part first comes into view, the WebGL scene is warmed up earlier than the person reaches it. Textures, shaders, and geometry are rendered as soon as forward of time, guaranteeing the primary seen body is already ready and the scroll expertise stays clean.
// Shader compilation and texture add usually occur on the primary render.
// Warming up the scene forward of time avoids that work touchdown on the primary
// seen body.
const warmUp = () => {
playing cards.forEach((m) => (m.seen = true));
renderer.compile(scene, cam); // compile all shaders
renderer.render(scene, cam); // add textures and initialize GPU assets
playing cards.forEach((m, i) => {
m.seen = wasVisible[i];
});
renderer.clear(); // discard the warm-up body
};
// Run as soon as on initialization, then once more in any case textures have loaded.
warmUp();
Rendering is pushed instantly by ScrollTrigger updates quite than a constantly working animation loop. A gsap.ticker solely subscribes whereas the part is inside one viewport of the display and there are nonetheless animations settling, resembling hover easing or the ripple impact, decreasing pointless work when the scene is idle.
To keep away from a noticeable hitch the primary time the gallery seems, the scene can also be explicitly warmed up. Shaders are compiled and textures uploaded to the GPU earlier than the part turns into seen, shifting that one-time initialization value off the person’s first scroll into the expertise.
Footer Wire Emblem & Smoke
The footer combines SVG, Internet Audio, and WebGL right into a single interactive expertise. The wireframe wordmark behaves like a set of guitar strings that may be plucked with the cursor, producing synthesized notes and animated waves. On the identical time, a separate WebGL smoke layer reacts to the dwell audio sign, responding not solely to person interplay but in addition to the frequency content material of the sound itself.
Every SVG Stroke Turns into a String
Each stroke of the SVG wordmark is handled as an unbiased string with its personal oscillation state. Hovering or clicking injects power into that string, inflicting it to vibrate whereas triggering a synthesized observe. As a result of every path maintains its personal state, a number of strings will be plucked independently, permitting overlapping interactions with out interfering with each other.
// parts/Footer/TrionnFooterLogo.tsx
for (const p of paths) {
if (!hasStroke(p)) proceed;
const ep = getEndpoints(p); // precise begin/finish factors of the SVG path
const state: StringState = {
x1: ep.x1,
y1: ep.y1,
x2: ep.x2,
y2: ep.y2,
amp: 0,
part: 0,
pace: 0,
cycles: 2.2,
observe: DEFAULT_SCALE[i % DEFAULT_SCALE.length] * (i % 2 ? 1 : 0.5),
depth:
i === 0
? 0
: Math.pow(i / (paths.size - 1), 1.25),
};
p.addEventListener("mouseenter", () => {
state.amp = hoverAmp;
state.pace = 18;
gsap.killTweensOf(state);
// Ease the string again to its resting state
gsap.to(state, {
amp: 0,
length: 0.9,
ease: "expo.out",
});
gsap.to(state, {
pace: 0,
length: 0.9,
ease: "expo.out",
});
pluckFluteDreamy(state.observe, state.depth);
pulseSmoke(0.4); // notify the fog layer
});
}
The Wave Is Drawn Procedurally
Relatively than counting on a CSS animation, the wave is recalculated and redrawn each body. This makes it doable to manage the amplitude, frequency, and damping of every string independently, so each interplay feels responsive and behaves like a plucked wire as a substitute of a looping animation.
const makeWavePath = (
x1,
y1,
x2,
y2,
amp,
part,
cycles,
) => {
const ux = (x2 - x1) / len;
const uy = (y2 - y1) / len; // unit vector alongside the string
const px = -uy;
const py = ux; // perpendicular ("wobble") route
let d = `M ${x1} ${y1}`;
for (let i = 1; i <= 26; i++) {
const t = i / 26;
const env = Math.sin(Math.PI * t); // zero on the ends, strongest on the middle
const wobble = Math.sin(
Math.PI * 2 * cycles * t + part,
);
const x =
x1 +
(x2 - x1) * t +
px * wobble * amp * env;
const y =
y1 +
(y2 - y1) * t +
py * wobble * amp * env;
d += ` L ${x} ${y}`;
}
return d;
};
// Replace solely strings which might be nonetheless shifting.
if (st.amp > 0.02 || st.pace > 0.02) {
st.part += st.pace * dt;
el.setAttribute(
"d",
makeWavePath(
st.x1,
st.y1,
st.x2,
st.y2,
st.amp,
st.part,
st.cycles,
),
);
}
Enlarging the Hit Space
As a result of the seen SVG strokes are only some pixels broad, interacting with them instantly could be irritating. As an alternative, every string has an invisible duplicate with a a lot thicker stroke that’s used completely for pointer occasions. This gives a a lot bigger hit space whereas preserving the visible look of the brand unchanged.
const clone = p.cloneNode(true) as SVGPathElement;
clone.setAttribute("stroke", "clear");
clone.fashion.strokeWidth = `${Math.max(12, strokeWidth * 18)}`; // a lot wider than the seen stroke
clone.setAttribute("pointer-events", "stroke");
clone.addEventListener("mouseenter", () => {
p.dispatchEvent(
new Occasion("mouseenter", {
bubbles: true,
}),
);
});
p.parentNode?.insertBefore(clone, p.nextSibling);
Synthesizing the Pluck Sound
Relatively than enjoying prerecorded audio, every pluck is synthesized in actual time utilizing the Internet Audio API. Three barely detuned sine-wave oscillators are layered collectively and fed via a suggestions delay, producing a tender, flute-like sound that responds immediately to each interplay.
const osc1 = ctx.createOscillator();
osc1.frequency.setValueAtTime(freq, now);
const osc2 = ctx.createOscillator();
osc2.frequency.setValueAtTime(freq * 2, now); // octave
const osc3 = ctx.createOscillator();
osc3.frequency.setValueAtTime(freq * 3, now); // harmonic
// Gradual vibrato utilized to the elemental oscillator
const lfo = ctx.createOscillator();
lfo.frequency.setValueAtTime(4.9, now);
lfoGain.join(osc1.frequency);
const delay = ctx.createDelay(1.0);
delay.delayTime.setValueAtTime(0.14, now);
const fb = ctx.createGain(); // suggestions loop for the echo/reverb tail
fb.achieve.linearRampToValueAtTime(
0.32 + depth * 0.12,
now + 0.05,
);
delay.join(echoLP);
echoLP.join(fb);
fb.join(delay);
Procedural Fog with a Fragment Shader
The fog impact is rendered as a single full-screen fragment shader quite than a particle system. Layered fractal noise (FBM) creates the bottom sample, whereas area warping breaks up repetition and offers the smoke a extra natural movement. The result’s a light-weight impact that constantly drifts upward and responds easily to person interplay.
// parts/Footer/FooterFog.tsx — fragment shader
float fbm(vec2 p) {
// Fractal Brownian Movement: layered noise at progressively smaller scales
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 3; i++) {
v += a * vnoise(p);
p = p * 2.1 + vec2(3.7, 8.3);
a *= 0.5;
}
return v;
}
float rise = T * 0.07; // sluggish upward drift over time
vec2 q = vec2(
uv.x * facet * 3.0,
(1.0 - y) * 4.5 + rise
);
float f = fbm(
q + 1.4 * fbm(q2 + ...)
+ ...
); // domain-warped noise creates natural, non-repeating smoke
// H is the hover power injected by brand interactions.
vec3 col = combine(charcoal, ashGrey, pow(f, 1.0));
col = combine(col, lightGrey, pow(f, 2.2));
col = combine(col, hoverTint, H * 0.55 * f);
Audio-Reactive Fog
The fog doesn’t merely reply to hover occasions. As an alternative, it listens to the identical audio graph used to synthesize the pluck sounds via a dwell AnalyserNode. This enables the shader to react to the precise frequency content material of the audio, making the motion and depth of the smoke replicate the sound being performed quite than a easy on/off set off.
// FooterFog reads from an AnalyserNode tapped off the brand's audio graph.
if (analyser && freqData) {
analyser.getByteFrequencyData(freqData);
let sum = 0;
// Measure the power within the mid-frequency vary.
for (let i = 2; i < freqData.size * 0.5; i++) {
sum += freqData[i];
}
const uncooked =
sum / (freqData.size * 0.5 * 255);
// Clean the response for a extra pure assault and decay.
freqEnergy +=
(uncooked - freqEnergy) * lerpFactor;
}
// Louder notes make the fog morph extra shortly.
morphOffset +=
(4.0 + freqEnergy * 16.0 + hoverBoost * 2.8) * dt;
The pluck sound is synthesized completely in actual time utilizing the Internet Audio API, combining three oscillators, a refined vibrato LFO, and a suggestions delay as a substitute of counting on prerecorded audio. To make the interplay really feel easy, every seen SVG stroke additionally has a a lot wider invisible duplicate that handles pointer occasions, permitting skinny traces to stay straightforward to hover and click on.
The brand and fog stay loosely coupled via a small shared ambiance context. The brand merely exposes strategies resembling pulseSmoke() and getSmokeAnalyser(), whereas the fog solely reacts to the info it receives. It doesn’t know what triggered the occasion—solely that new power is out there—preserving the impact cleanly separated and straightforward to reuse elsewhere.
Lion Reveal & Curtain Drag
This part combines a depth mapped picture with interactive WebGL results to create the phantasm of depth. A portrait of the lion responds to cursor motion via a fraction shader, whereas draggable curtain strips peel again with spring physics to disclose the picture beneath. Pulling the curtain triggers a synchronized sound sequence, starting with the material motion and ending with a lion’s growl.
Sequencing the Reveal
The lion animation is deliberately delayed till the headline animation has completed. Ready for the textual content to finish creates a transparent visible rhythm and ensures the reveal looks like a continuation of the story, quite than competing for the person’s consideration.
// parts/Sections/About/AboutHero.tsx
useEffect(() => {
if (!earlyStart || introStartedRef.present) return;
introStartedRef.present = true;
// Reveal the headline first.
mainHeadingRef.present?.play();
const tLion = window.setTimeout(() => {
doc.documentElement.dataset.trionnLionStart = "true";
window.dispatchEvent(
new CustomEvent("trionn:about-lion-start"),
);
}, headingAnimMs);
// Subtitle and scroll trace comply with shortly after.
return () => clearTimeout(tLion);
}, [earlyStart]);
// parts/Sections/About/AboutLion.tsx
useEffect(() => {
const handler = () => setShouldInit(true);
if (
doc.documentElement.dataset.trionnLionStart ===
"true"
) {
setShouldInit(true); // Occasion has already fired.
return;
}
window.addEventListener(
"trionn:about-lion-start",
handler,
{ as soon as: true },
);
return () => {
window.removeEventListener(
"trionn:about-lion-start",
handler,
);
};
}, []);
Depth Mapped Portrait
The lion portrait creates the phantasm of depth utilizing a single fragment shader and a precomputed depth map quite than actual 3D geometry. Because the cursor strikes, the shader offsets the picture primarily based on the saved depth info, producing a convincing parallax impact whereas rendering solely a single textured aircraft.
// AboutLion.tsx — fragment shader
vec2 mouse = (uMouseEase - 0.5) * vec2(2.0, -2.0); // normalize to -1...1 (Y flipped)
float depth = texture2D(uDepth, contained).r; // precomputed depth map
float respiration =
sin(uTime * 0.0012) * 0.5 + 0.5; // refined idle movement
float quantity =
(0.03 + 0.012 * respiration) * uHover;
vec2 disp = mouse * depth * quantity; // brighter pixels shift extra
vec2 uv = contained - disp;
// Small RGB offset creates a refined chromatic fringe.
float r = texture2D(uImage, uv + disp * 0.16).r;
float g = texture2D(uImage, uv).g;
float b = texture2D(uImage, uv - disp * 0.16).b;
gl_FragColor = vec4(
combine(bg, vec3(r, g, b), a),
1.0
);
// JavaScript: ease the cursor towards its goal place.
state.eased.x +=
(state.mouse.x - state.eased.x) * 0.07;
state.eased.y +=
(state.mouse.y - state.eased.y) * 0.07;
gl.uniform2f(
uniforms.mouseEase,
state.eased.x,
state.eased.y,
);
Simulating the Curtain
Every curtain strip behaves like a easy spring that’s up to date each body. When a strip is dragged, it naturally eases again to its resting place, whereas the encircling strips are pulled alongside to protect the form of the curtain. This creates the impression of a steady piece of cloth quite than a set of unbiased parts.
// physicsStep() — runs as soon as per body for each strip
const sp = -offY[i] * 0.12; // spring power pulling the strip again to relaxation
velY[i] = (velY[i] + sp) * 0.65; // apply damping
offY[i] += velY[i];
// Whereas dragging, neighboring strips are pulled alongside to keep away from
// seen gaps or overlaps, creating the impression of a steady curtain.
if (dy > 0) {
for (let ii = 1; ii < stripCount; ii++) {
const overlap = aboveBot - thisRest;
if (overlap <= 0) break;
const sp = (overlap - offY[ni]) * 0.22;
velY[ni] = (velY[ni] + sp) * 0.62;
offY[ni] += velY[ni];
}
}
Drawing the Curtain
Every strip is drawn procedurally on a 2D canvas as a substitute of utilizing a static picture. The form is warped by a Gaussian drag envelope centered on the person’s seize level, making a clean deformation that spreads naturally throughout the strip and carefully mimics the habits of cloth.
const updateDragEnvelope = () => {
const clickX = dragSeg / SEGS; // the place alongside the strip you grabbed it
for (let s = 0; s <= SEGS; s++) {
const t = s / SEGS;
const dx = t - clickX;
dragEnvelope[s] = Math.exp(-(dx * dx) * SIGMA_INV); // Gaussian falloff from the seize level
}
};
// Every strip’s path bends most the place you grabbed it, really fizzling out towards its ends:
const py = restY + displacement * dragEnvelope[s];
Motion Pushed Sound
The audio is tied on to the person’s interplay with the curtain. The primary sound impact loops solely whereas an actual drag is occurring, utilizing a particular part of the recording to create a steady texture. Extra sounds are triggered primarily based on the progress of the motion, whereas easy clicks or incomplete gestures stay silent.
const startCurtainSound = () => {
// Loop simply the center part of the curtain SFX for so long as the drag continues
dragSource.buffer = curtainBuffer;
dragSource.loop = true;
dragSource.loopStart = Math.min(0.1, length * 0.1);
dragSource.loopEnd = Math.min(1.5, length * 0.5);
dragSource.begin(0, dragStart);
};
const maybeTriggerSounds = () => {
if (!curtainStarted && dragSpeed > DRAG_MOVE_PX) {
startCurtainSound();
}
// Growl solely fires as soon as the curtain has been audibly open for CURTAIN_LEAD_S
// so it by no means performs on a fast faucet, solely an actual pull.
if (
!growlStarted &&
curtainStarted &&
Math.abs(offY[dragStrip]) > CURTAIN_OPEN_PX &&
audioCtx.currentTime - curtainStartedAt >= CURTAIN_LEAD_S
) {
startGrowlSound();
}
};
// On launch: solely play the “thud” if one thing truly moved.
// A plain click on must be silent.
if (!playedSomething) return;
A CustomEvent (trionn:about-lion-start) connects the 2 parts, triggering the lion sequence solely after the hero headline has completed revealing. Not like the hero image and footer brand, this interplay makes use of recorded audio property such because the curtain motion and lion growl as a substitute of synthesized sound. The clips are dynamically manipulated via looping sections, randomized begin offsets, and drag-speed-driven quantity adjustments, making a extra responsive audio expertise that evolves with the interplay.
Gallery Scatter Wall
The “Work onerous. Play loud.” part makes use of a scattered picture structure the place eleven staff photographs animate into place from completely different instructions throughout scroll. Every picture is assigned a randomized place from a predefined set of collision-safe slots, making a structure that feels natural whereas remaining managed. Clicking a photograph brings it ahead, adjusts surrounding pictures to keep away from overlap, and extracts its dominant colour client-side to affect the background.
Randomized positions from predefined slots
The gallery structure makes use of a hard and fast assortment of positions quite than utterly random coordinates. Every picture receives a shuffled slot at runtime, which means the association adjustments between visits whereas preserving each picture inside a managed, non-overlapping space. Small place offsets and rotation values are added afterward to create a extra pure scattered impact.
// parts/TrionnGallery/TrionnGallery.tsx
// Every picture defines a desktop/cellular place fraction. The SET of
// positions is shuffled at runtime, so a given picture doesn’t at all times land
// in the identical slot. This retains the structure collision-free whereas nonetheless
// introducing variation.
const cells = IMAGES.map((img) =>
isMobile ? img.place.cellular : img.place.desktop
);
const shuffled = cells
.map((cell) => ({
cell,
type: Math.random(),
}))
.type((a, b) => a.type - b.type)
.map((x) => x.cell);
return gadgets.map((merchandise, index) => {
const rotation = randomBetween(-4, 4);
// Account for the rotated bounding field when calculating limits
const bounds = getRotatedBounds(
rect.width,
rect.top,
rotation
);
const cell = shuffled[index];
// Add small variations so the structure doesn't really feel too inflexible
const jitterX = randomBetween(-36, 36);
const jitterY = randomBetween(-36, 36);
return {
x: clamp(
cell[0] * vw - vw / 2 + jitterX,
minX,
maxX
),
y: clamp(
cell[1] * vh - vh / 2 + jitterY,
minY,
maxY
),
r: rotation,
s: 1,
};
});
Multi-directional entrance animation
As an alternative of shifting each picture in from the identical route, every picture begins from one in all a number of off-screen positions earlier than settling into its closing location. This creates a extra dynamic reveal the place the gallery looks like it’s assembling itself across the viewer.
const getStartPosition = (index: quantity) => {
const hole = Math.max(vw, vh) * 0.72;
const begins = [
{
x: -vw / 2 - gap,
y: -vh / 2 - 80,
}, // far off-screen, each corner/edge
{
x: vw / 2 + gap,
y: -vh / 2 + 30,
},
{
x: -80,
y: -vh / 2 - gap,
}, // from the top
{
x: 130,
y: vh / 2 + gap,
}, // from the bottom
// …10 total positions, cycled by index
];
return begins[index % starts.length];
};
Scroll managed entrance sequence
The gallery reveal is managed by a pinned scroll timeline that staggers every picture’s entrance earlier than transitioning into the ultimate stripe wipe. Every picture is launched with a slight delay, making a sequential composition, whereas the second animation part is triggered solely after the gallery has absolutely settled.
const animationEnd = GALLERY_VH / (GALLERY_VH + STRIPE_HOLD_VH);
// Fraction of the pinned scroll length reserved for the picture entrance
galleryTimeline = gsap.timeline({
scrollTrigger: {
set off: part,
begin: "high high",
finish: `+=${GALLERY_VH + STRIPE_HOLD_VH}%`,
scrub: 0.6,
pin: true,
onUpdate: (self) => {
const holdT = Math.max(
0,
Math.min(
1,
(self.progress - animationEnd) / (1 - animationEnd)
)
);
// Stripes solely begin as soon as the gallery entrance is full
if (stripesTL) stripesTL.progress(holdT);
},
},
});
gadgets.forEach((merchandise, index) => {
galleryTimeline!.to(
merchandise,
{
x: finish.x,
y: finish.y,
rotate: finish.r,
length: 1.2,
},
index * 0.34
);
});
Dominant colour extraction from pictures
The chosen picture is analyzed instantly within the browser to find out its dominant colour with out requiring any exterior processing. The picture is first diminished to a smaller canvas for quicker sampling, then pixels are grouped into colour buckets to establish essentially the most distinguished tones whereas ignoring clear, very darkish, vivid, or low-saturation areas. The ensuing colour is used to dynamically tint the gallery background.
const getDominantImageColor = (img: HTMLImageElement): RGB => {
const canvas = doc.createElement("canvas");
canvas.width = canvas.top = 72; // downsample for pace
ctx.drawImage(img, 0, 0, 72, 72);
const pixels = ctx.getImageData(0, 0, 72, 72).information;
const buckets = new Map(); // quantize colours into coarse buckets
for (let i = 0; i < pixels.size; i += 4) {
const [r, g, b, a] = [
pixels[i],
pixels[i + 1],
pixels[i + 2],
pixels[i + 3],
];
if (a < 220) proceed;
const brightness = (r + g + b) / 3;
const saturation = Math.max(r, g, b) - Math.min(r, g, b);
// Skip near-black, near-white, and grey areas
if (brightness < 25 || brightness > 245 || saturation < 12) {
proceed;
}
const key = `${Math.spherical(r / 22) * 22},${Math.spherical(g / 22) * 22},${Math.spherical(b / 22) * 22}`;
// Accumulate rely + a "vividness" rating per bucket...
}
// Decide the bucket with one of the best rely × rating weighting,
// then common its uncooked pixels.
};
Interactive picture motion with collision response
Clicking a photograph creates a pull-forward impact that briefly strikes it towards the viewer earlier than returning it to its authentic place. On the identical time, close by overlapping photographs are detected and pushed away from the interplay level, making a refined bodily response that makes the scattered structure really feel extra dynamic.
const onClick = () => {
if (isCardAnimating) return;
isCardAnimating = true;
updateBackgroundTone(merchandise);
moveOverlappingCards(merchandise); // see under
gsap.timeline({
onComplete: () => {
resetInnerCard(merchandise);
isCardAnimating = false;
},
})
.to(interior, {
x: pullX,
y: pullY,
rotation: route * 4.5,
scale: 1.035,
opacity: 0.18,
length: 0.34,
})
.set(merchandise, {
zIndex: activeZIndex,
})
.to(interior, {
x: 0,
y: 0,
rotation: 0,
scale: 1,
opacity: 1,
length: 0.56,
});
};
const moveOverlappingCards = (clickedItem) => {
gadgets.forEach((otherItem) => {
if (!isOverlapping(clickedRect, otherItem.getBoundingClientRect())) {
return;
}
const dx = otherCenterX >= clickedCenterX ? 1 : -1;
// Push away from the clicked picture
gsap.timeline()
.to(interior, {
x: dx * gsap.utils.random(18, 34),
rotation: dx * gsap.utils.random(1.4, 2.8),
length: 0.34,
})
.to(interior, {
x: 0,
y: 0,
rotation: 0,
length: 0.52,
}); // spring again
});
};
Consumer-side picture mounting and colour sampling
The gallery pictures are mounted solely after the element is rendered on the consumer, avoiding pointless server-side picture loading for a bit that relies upon completely on interplay and animation. This retains the preliminary render lighter whereas nonetheless permitting the pictures to be processed within the browser for colour extraction.
const mounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
// …
{
mounted && (
)
}
The dominant colour extraction is carried out by decreasing every picture to a 72×72 canvas and grouping comparable pixel values into colour buckets. Close to-black, near-white, and low-saturation pixels are faraway from the calculation so pictures with impartial backgrounds can nonetheless produce a significant accent colour. The pinned scroll timeline is split into two phases, with the picture entrance finishing earlier than the stripe wipe begins, following the identical progressive transition sample used throughout the Companies and Hero sections.
Closing Notes
Wanting again, a shared canvasManager structure from the start would have simplified the administration of a number of WebGL experiences throughout the positioning. One of many major classes was that the ultimate degree of polish got here much less from particular person results and extra from rigorously synchronizing each layer of the expertise, together with scrolling, animations, transitions, and audio.
Sustaining constant patterns all through the undertaking, resembling a shared GSAP ticker, idle-task scheduling, GPU-friendly animations, and synthesized Internet Audio, helped hold the expertise performant whereas preserving the extent of element throughout each part. This identical consideration to timing and interplay was carried via to the smaller moments as properly, together with the footer, the place the expertise continues past the principle content material.









