18.8 C
New York
Wednesday, May 6, 2026

From Shader Uniforms to Clip-Path Wipes: How GSAP Drives My Portfolio



My title is Thibault Guignand. I work each as a freelancer and full-time worker (CDI). It’s a twin setup that exposes me to each form of venture, from company work to completely artistic ones. My long-term aim is to shift 100% of my time towards artistic work.

This redesign was, in a means, a lab. A option to measure the place I stand within the artistic net sport as we speak, and what I’m price in it. My earlier portfolio already carried the seed of this design course; I needed to select it again up, push it additional, and convey it in keeping with the years of expertise accrued since.

My every day consumption comes from creatives I observe intently (Aristide Benoist, Cathy Dole, Corentin Bernabou, and others) and from specialised platforms like Awwwards. I don’t have a look at their work to repeat; I have a look at it to set a bar for myself.

A number of weeks in whole. The core construct got here collectively quick; sharpening it stretched the timeline. Making each format (desktop, pill, cellular, decreased movement) behave precisely the way in which I needed took most of it.

I began the WebGL layer with Three.js, the apparent alternative. Midway in, I rewrote all the pieces in OGL. The tradeoff was price it: lighter bundle, leaner API, and a codebase I felt I really owned line by line.

Tech Stack & Instruments

The stack is deliberately mainstream. Selecting widely-deployed instruments means I can display mastery of the identical constructing blocks studios already use.

Vite + React 18 + TypeScript. Default reply for me now: quick dev loop, typed confidence, zero shock for anybody studying the code.

GSAP. Fan since day one, and now that it’s totally free, it’s a no brainer. Inexperienced-heart love. SplitText, ScrollTrigger, and the timeline API are unmatched for the form of movement I would like.

OGL. Lined within the backstory: lightness over three.js, and an excuse to dig deeper into low-level WebGL.

Lenis. Native scroll is simply too brittle for tightly-coupled ScrollTrigger animations. Lenis offers me easy scrolling plus a single supply of fact I can sync with GSAP’s ticker.

SCSS + BEM. Behavior and private desire. Once I’m writing shaders and bespoke layouts, a predictable naming conference retains my head clear.

i18n (FR/EN). Inventive awards platforms have worldwide juries; bilingual isn’t non-obligatory if I would like the positioning judged by the widest doable viewers.

Design tooling. I sketched the format grid in Figma to lock the rhythm and proportions, then shipped it as a part of the manufacturing CSS. Press Cmd/Ctrl + G wherever on the positioning and the grid overlays in place: identical gutters, identical rows, identical columns. The precise content material sits on that grid, not approximating it. Every thing else (typography, movement, transitions) I designed straight in code. Fewer handoffs, tighter suggestions loop.

Characteristic Breakdowns

Video Carousel Transition

The homepage sits on prime of a full-screen video carousel: hover any venture rectangle and the present video melts into the subsequent via a block-reveal sample distorted by noise, with a chromatic aberration that peaks mid-transition.

The way it works. A full-screen triangle in NDC house runs the fragment shader beneath. Three issues occur in parallel:

  1. Block reveal masks: UVs are pixelated and sampled in opposition to a static noise texture. A step() in opposition to the progress uniform turns this right into a binary masks that grows block by block. No linear wipe; each pixel switches abruptly however not concurrently.
  2. Displacement: a second noise pattern, scrolling in time, warps UVs alongside a 2D course. Depth follows parabola(progress, 2.) so the warp peaks at 50% progress and returns to zero.
  3. Chromatic aberration: purple and blue channels of each textures are sampled with reverse offsets. Similar parabola easing.
float dt = parabola(progress, 2.);

// Block reveal: static noise pixelated, in comparison with progress
vec2 blockUv = flooring(vUv * uNoisePixelSize) / uNoisePixelSize;
float noiseVal = texture2D(displacement, blockUv).g;
float intpl = step(noiseVal, progress);

// Warp
vec2 displaceDir = (noise.rg - 0.5) * 2.0;
vec2 warpedUv = uv + displaceDir * dt * uDisplaceIntensity;

// Chromatic aberration
float shift = dt * uRGBShift;
t1.r = texture2D(texture1, warpedUv + vec2(shift, 0.0)).r;
t1.g = texture2D(texture1, warpedUv).g;
t1.b = texture2D(texture1, warpedUv - vec2(shift, 0.0)).b;

// identical for t2…
gl_FragColor = combine(t1, t2, intpl);

GSAP × WebGL: one uniform because the bridge. The whole choreography (each block snap, each pixel of warp, each chromatic offset) is pushed by a single progress quantity between 0 and 1. That quantity lives in JavaScript, tweened by a GSAP timeline with a customized ease; each animation body I copy it into the progress uniform and OGL sends it to the GPU. The shader is stateless, GSAP owns the movement curve, and I can swap eases or chain timelines with out touching a line of GLSL. It’s the one sample I reuse throughout each impact within the venture.

Per-frame add, the minimal wanted. Video textures should be re-uploaded every body, because the browser has decoded new pixels. I flag texture.needsUpdate = true solely on the 2 textures at the moment concerned in a transition (supply + vacation spot), by no means on the entire pool. Exterior of a transition, the carousel falls again to native <video> playback with zero GPU uploads.

Flowmap Textual content Distortion

Throughout the positioning, venture titles and hero photos react to the cursor with a fluid distortion and a velocity-driven chromatic rainbow. It’s the form of impact that dies if you happen to stutter the mouse over it, so each millisecond counts.

The way it works. OGL’s Flowmap helper writes the cursor’s velocity into an off-screen RG texture every body, accumulating a fading “brush stroke” of movement. The shader samples that flowmap to distort the textual content UVs, then does a second go of directional chromatic aberration: as an alternative of a symmetric RGB shift, every channel is offset alongside the vector from the mouse to the pixel, with completely different magnitudes per channel (R at 1.5×, G at 0.5×, B at 1.8×).

// Directional chromatic aberration: not centered, guided by cursor
vec2 toMouse = vUv - uMouse;

float affect =
    smoothstep(uRadius, 0.0, size(toMouse)) * uVelo;

vec2 offset =
    normalize(toMouse) * affect * uChromaticIntensity;

// RGB cut up sampling
float r = texture2D(tWater, baseUV - offset * 1.5).r;
float g = texture2D(tWater, baseUV + offset * 0.5).g;
float b = texture2D(tWater, baseUV + offset * 1.8).b;

// Rainbow kick when velocity is excessive: sin() with 120° part offsets
if (uVelo > 0.01) {

    float hueShift =
        uTime * 0.01 + size(toMouse) * 2.0;

    r = combine(
        r,
        sin(hueShift) * 0.5 + 0.5,
        uVelo * uColorShift
    );

    g = combine(
        g,
        sin(hueShift + 2.094) * 0.5 + 0.5,
        uVelo * uColorShift
    );

    b = combine(
        b,
        sin(hueShift + 4.188) * 0.5 + 0.5,
        uVelo * uColorShift
    );
}

The rainbow makes use of the oldest trick within the ebook: three sin() calls separated by 2π/3 rad, mapped to R, G, B. It solely kicks in when the cursor is shifting quick sufficient, which retains the impact quiet throughout idle hover and loud throughout quick swipes.

Mount as soon as, swap textures. That is the optimization I’m proudest of. My first model mounted a contemporary WebGL context for every venture title. Clear in React phrases, catastrophic in observe. GPU reminiscence saved climbing; the rainbow stuttered by the fourth hover. The rewrite retains a single FlowmapEffect mounted on the HomePage degree and accepts the present goal as an imageSrc prop. The context survives, solely the feel swaps. Paired with an idle guard that stops the rAF loop after 90 frames with out cursor enter (and resumes on the subsequent mousemove), the impact prices virtually nothing once you’re not utilizing it.

Subsequent-Challenge Scroll Morph

On the backside of each venture web page, the “Subsequent venture” preview expands as you scroll: a clipped, scaled-up background unclips into view whereas an SVG circle traces a 0→100% counter. Hit 100% and also you’re routinely navigated to the subsequent venture. Scroll again up and all the pieces reverses, and the navigation is cancelled.

The way it works. A single ScrollTrigger with scrub: 1 drives the animation. Its onUpdate callback writes 4 values on to the DOM each body: no React state, no reconciliation.

onUpdate: (self) => {
  const progress = self.progress;
  const % = Math.spherical(progress * 100);

  // Counter
  numberEl.textContent = String(% >= 99 ? 100 : %);

  // Background morph: scale + inset clip-path
  const bgScale = 1.3 - 0.3 * progress;

  const insetV = Math.max(0, 20 - 20 * progress);
  const insetH = Math.max(0, 40 - 40 * progress);

  bgEl.fashion.remodel = `scale(${bgScale})`;
  bgEl.fashion.clipPath = `inset(${insetV}% ${insetH}% ${insetV}% ${insetH}%)`;

  // SVG progress circle
  circleEl.fashion.strokeDashoffset =
    String(CIRCUMFERENCE - progress * CIRCUMFERENCE);

  // Auto-navigation test
  if (% >= 100 && state === "idle" && hasSeenLowProgress) {
    // set off web page change
  }
};

Writing component.fashion.* as an alternative of calling setState() saves a full React render tree per body. On a 120 Hz laptop computer, that’s the distinction between butter and a slideshow.

A state machine, as a result of scroll is unpredictable. The auto-navigation isn’t so simple as “attain 100% → go”. Individuals flick-scroll previous the part, land on a fraction reload at 100%, change their thoughts midway. I ended up with a three-state machine (idle → triggered → navigating) plus two guards: a hasSeenLowProgress flag (you solely auto-navigate if you happen to really scrolled from the highest, not if you happen to landed there) and a velocity ceiling (if scrollTrigger.getVelocity() > 2000 we skip the set off). Scroll again up earlier than the 250 ms commit timeout and onLeaveBack rolls all the pieces again to idle. No phantom navigations.

The identical animation, two drivers. The part can also be clickable. A click on spawns a GSAP tween from the present scroll progress to 1 and scrolls the web page to match in parallel. An identical DOM mutations, equivalent visible end result, only a completely different time supply. As a result of the scrub path already writes all the pieces via component.fashion.*, hijacking it with a GSAP tween took about ten strains.

Web page Transitions (GSAP + View Transitions)

Leaving the homepage isn’t a tough minimize. The WebGL background, grid overlay, aspect texts, and customized cursor fade collectively; a quarter-second later the content material layer follows; then the browser’s View Transition API takes over for the ultimate clip-path morph. Three applied sciences (GSAP, View Transitions, React) should cooperate with out stepping on one another.

Preload races the fadeout. The second a hyperlink is clicked, two issues begin in parallel: the visible fadeout and the info fetch. The dynamic import() of the subsequent route’s chunk and the hero picture preload hearth earlier than GSAP paints a single body. By the point the timeline finishes (~0.6 s), each have normally landed.

// Hearth preloads first: they race the GSAP fadeout
const chunkReady = chunkPreloaders[routeChunk]().catch(() => {});
const imageReady = venture?.heroImage
  ? preloadImage(venture.heroImage)
  : Promise.resolve();

// Staged fadeout, all parallel with the community
const tl = gsap.timeline();

tl.to(
  [webglBg, gridOverlay, sideTexts, customCursor],
  { opacity: 0, length: 0.3, ease: "power2.inOut" },
  0
).to(
  contentEl,
  { opacity: 0, length: 0.35, ease: "power2.inOut" },
  0.25
);

await tl.then();
await Promise.all([chunkReady, imageReady]);

await startPageTransition(() => {
  flushSync(() => {
    navigate(path);
  });
});

flushSync is the element that makes the View Transition work. doc.startViewTransition takes a callback, captures the DOM earlier than you mutate it, runs the callback, then captures the DOM after. React Router’s navigate() is asynchronous, so with out intervention, the VT captures the outdated web page twice and also you get no animation. Wrapping navigate() in flushSync forces React to commit the brand new route synchronously contained in the VT callback. Small element, infuriating bug if you happen to miss it.

Textual content Reveal: One Sample Used In every single place

The identical reveal language performs throughout your complete website. Each block of textual content that seems makes use of the identical mixture: a GSAP SplitText for char or line construction, a scramble impact to resolve characters, and a clip-path wipe layered on prime. Centralizing it in a single utility means a tweak to the curve in a single place adjustments the rhythm of the entire website.

The 2 results run collectively, not in sequence. Once you scramble a line from random characters towards the ultimate string, the left edge resolves first. Layering a clip-path that opens left-to-right on the identical velocity means the person solely ever sees the a part of the textual content that’s already legible. Nothing reveals as visible noise; nothing reveals all of sudden.

gsap.to(lineEl, {
  length,
  ease: "none",
  scrambleText: {
    textual content: lineText,
    chars: SCRAMBLE_CHARS,
    revealDelay,
    velocity,
  },
  onStart: () => {
    // Wipe runs in parallel with the scramble resolve
    gsap.to(lineEl, {
      clipPath: "inset(0 0% 0 0)",
      length: 0.6,
      ease: "power2.out",
    });
  },
});

Pre-scramble on the proper size, lock the peak. Two particulars that cease the format from respiratory throughout the animation:

  1. Earlier than the tween begins, each char of the goal string is changed with a random character from SCRAMBLE_CHARS. Areas are preserved. The road occupies its remaining width earlier than resolving, so the gradual character swap causes no reflow.
  2. The father or mother peak is locked to its measured getBoundingClientRect().peak earlier than SplitText runs. SplitText wraps every line in its personal block; with out the lock the wrapper briefly collapses and the remainder of the web page jumps.

The character set issues. SCRAMBLE_CHARS = 'A!B@C#D$EpercentF&G*H?J[K]L{M}N=O+P-QRSTUVWXYZ'. Mixing letters with punctuation offers the decision that “decoding” really feel slightly than a easy fade between alphabets. The textual content feels prefer it’s being pulled out of a buffer.

Visible & Interplay Design

A website for net individuals. I designed this portfolio for the individuals who’ll scroll via it with dev instruments open. The visible language is supposed to learn as a technical handshake. If what “View Transition API” or “flowmap” means, the positioning is winking at you. That’s the viewers I need to work with.

Results I backed into slightly than deliberate. Chromatic aberration didn’t begin as a stylistic determination. It was a check to see how far I might push OGL’s texture sampling. Someplace between the third and fourth iteration, it stopped wanting like a tech demo and began wanting intentional, so I saved it, and repeated it throughout the video transition and the flowmap hover. It’s develop into the visible thread tying the positioning collectively.

Decreased movement, dealt with correctly. Early variations of this website broke onerous on prefers-reduced-motion: scale back. Cassie Evans’ GSAP talks have been what made me cease treating decreased movement as a disable-flag and begin treating it as a parallel design: an actual, degraded model that also conveys the identical intent with out the vestibular value.

Structure & Construction

Nothing revolutionary right here. Disciplined greater than intelligent.

src/
├── parts/         // UI constructing blocks (customized cursor, minimap, cellular menu, intro, and so on.)
├── contexts/           // AppStateContext, WebGLContext
├── knowledge/               // projectsData.ts + image-dimensions.json + lqip-data.json
├── hooks/              // animation & transition logic
├── i18n/               // routes.ts + locales/{fr,en}/*.json
├── pages/              // HomePage, AboutPage, ProjectPage
├── suppliers/          // LenisProvider
├── companies/           // lenisService: singleton synced with GSAP's ticker
├── shaders/            // GLSL, one folder per impact
├── types/             // SCSS (BEM)
├── utils/              // scrambleText, prefersReducedMotion, imagePreloadCache, viewTransitions
└── webgl/              // Sketch.ts, FlowmapEffect.ts: uncooked OGL

Hooks vs companies is the one cut up that mattered. Hooks personal per-component lifecycle (setup, cleanup, ref juggling). Providers are singletons that outlive any part. Lenis has to outlive route adjustments as a result of the scroll place belongs to the doc, not the web page.

Efficiency boils right down to 4 habits I utilized all over the place one thing ran each body:

  1. Direct DOM mutations (component.fashion.*, textContent) as an alternative of React state. At 120 Hz, a render tree reconciliation is ~8 ms I don’t have.
  2. Sure, persistent objects: boundRender = this.render.bind(this) cached as soon as within the constructor; the flowmap mounted as soon as on the web page root; Vec2.set() as an alternative of new Vec2().
  3. Idle guards: the flowmap’s rAF loop suspends after 90 frames with out enter; the video carousel falls again to native <video> exterior transitions.
  4. Construct-time picture metadata: image-dimensions.json (zero CLS) and lqip-data.json (inline base64 blur-ups), plus per-route chunk preloading on hover.

Reflections

I poured vitality into this. The purpose wasn’t solely to ship a portfolio. It was to take advantage of completed factor I’d ever made, to push each element (UI, design, accessibility, efficiency) till I felt I’d genuinely realized one thing alongside the way in which. The unstated aim was to contribute, in a tiny means, to elevating the bar for what individuals anticipate from the net. I don’t know if I succeeded. What I do know is that I left all of it on the sphere.

What labored

  • The persistent flowmap sample: mounting the WebGL part as soon as on the web page root and swapping textures by way of a prop as an alternative of remounting per venture. Eradicated a memory-leak class solely.
  • Driving each WebGL impact from a single GSAP-tweened uniform. As soon as that sample clicked, the remainder of the venture was simply selecting eases.
  • Treating decreased movement as a parallel design, not a fallback. Cassie Evans modified how I take into consideration accessibility, and the venture shipped higher for it.

What was onerous

  • Safari + View Transitions + clip-path. Safari caches clip-path values on GPU layers so long as ::view-transition-* pseudo-elements are energetic. Reset the worth throughout the transition and Safari ignores it till you power a repaint. Recognized accidentally, fastened with a 50 ms post-transition buffer and a compelled reflow (void el.offsetHeight). It’s the form of bug you possibly can’t discover on Stack Overflow as a result of the proper key phrases don’t exist but.
  • The preload pipeline didn’t converge on the primary attempt. Trying on the git log, perf: seems ten occasions in a row over just a few weeks. Preload all the pieces → too aggressive, blocks preliminary render. Preload nothing → flashes. Finally I landed on: preload fonts instantly, hero picture on hover, first venture throughout the intro, defer the remainder. Ten commits to get there.

What I’d do in a different way

Begin with OGL, skip the three.js detour. Already talked about within the backstory, however with hindsight, the day I realized Mesh, Program, Texture from OGL’s supply was the day this venture really began.

Attain for Sanity earlier. A couple of of the friction factors I constructed workarounds for (venture knowledge in a typed file, translations unfold throughout JSON, picture and video property dealt with by hand) are precisely what Sanity is designed to resolve. It’s a genuinely distinctive software, and the subsequent iteration of the content material layer will begin there.



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles