My identify is Thibault Guignand. I work each as a freelancer and full-time worker (CDI). It’s a twin setup that exposes me to each type 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 method to measure the place I stand within the artistic internet recreation immediately, and what I’m value in it. My earlier portfolio already carried the seed of this design course; I needed to select it again up, push it additional, and produce it according to the years of expertise accrued since.
My day by day consumption comes from creatives I observe carefully (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 complete. The core construct got here collectively quick; sharpening it stretched the timeline. Making each format (desktop, pill, cellular, lowered movement) behave precisely the best way I needed took most of it.
I began the WebGL layer with Three.js, the apparent selection. Midway in, I rewrote all the pieces in OGL. The tradeoff was value it: lighter bundle, leaner API, and a codebase I felt I really owned line by line.
Tech Stack & Instruments
The stack is deliberately mainstream. Choosing widely-deployed instruments means I can show 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 type 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 clean scrolling plus a single supply of fact I can sync with GSAP’s ticker.
SCSS + BEM. Behavior and private desire. After 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 optionally available if I would like the location judged by the widest attainable viewers.
Design tooling. I sketched the structure 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 location and the grid overlays in place: identical gutters, identical rows, identical columns. The precise content material sits on that grid, not approximating it. Every part else (typography, movement, transitions) I designed straight in code. Fewer handoffs, tighter suggestions loop.

Characteristic Breakdowns
Video Carousel Transition
The homepage sits on high of a full-screen video carousel: hover any venture rectangle and the present video melts into the following by means of 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:
- Block reveal masks: UVs are pixelated and sampled towards a static noise texture. A
step()towards 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. - 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. - Chromatic aberration: purple and blue channels of each textures are sampled with reverse offsets. Identical parabola easing.
float dt = parabola(progress, 2.);
// Block reveal: static noise pixelated, in comparison with progress
vec2 blockUv = ground(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. All the 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 have to be re-uploaded every body, for the reason that browser has decoded new pixels. I flag texture.needsUpdate = true solely on the 2 textures presently concerned in a transition (supply + vacation spot), by no means on the entire pool. Outdoors of a transition, the carousel falls again to native playback with zero GPU uploads.
Flowmap Textual content Distortion
Throughout the location, venture titles and hero photos react to the cursor with a fluid distortion and a velocity-driven chromatic rainbow. It’s the type of impact that dies when you 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 a substitute of a symmetric RGB shift, every channel is offset alongside the vector from the mouse to the pixel, with totally 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 e book: 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 recent WebGL context for every venture title. Clear in React phrases, catastrophic in follow. GPU reminiscence saved climbing; the rainbow stuttered by the fourth hover. The rewrite retains a single FlowmapEffect mounted on the HomePage stage 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 following mousemove), the impact prices nearly nothing whenever you’re not utilizing it.
Subsequent-Undertaking 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 following 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 p.c = Math.spherical(progress * 100);
// Counter
numberEl.textContent = String(p.c >= 99 ? 100 : p.c);
// 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.model.remodel = `scale(${bgScale})`;
bgEl.model.clipPath = `inset(${insetV}% ${insetH}% ${insetV}% ${insetH}%)`;
// SVG progress circle
circleEl.model.strokeDashoffset =
String(CIRCUMFERENCE - progress * CIRCUMFERENCE);
// Auto-navigation test
if (p.c >= 100 && state === "idle" && hasSeenLowProgress) {
// set off web page change
}
};
Writing component.model.* as a substitute 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”. Folks 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 when you really scrolled from the highest, not when you 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. Similar DOM mutations, similar visible outcome, only a totally different time supply. As a result of the scrub path already writes all the pieces by means of component.model.*, hijacking it with a GSAP tween took about ten strains.
Web page Transitions (GSAP + View Transitions)
Leaving the homepage isn’t a tough reduce. 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) need to 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 information fetch. The dynamic import() of the following 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 often landed.
// Fireplace 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 when you miss it.
Textual content Reveal: One Sample Used All over the place
The identical reveal language performs throughout the whole 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 high. 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 abruptly.
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 structure from respiratory throughout the animation:
- 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 ultimate width earlier than resolving, so the gradual character swap causes no reflow. - The father or mother top is locked to its measured
getBoundingClientRect().topearlier thanSplitTextruns. 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 somewhat than a clean fade between alphabets. The textual content feels prefer it’s being pulled out of a buffer.
Visible & Interplay Design
A website for internet individuals. I designed this portfolio for the individuals who’ll scroll by means of it with dev instruments open. The visible language is supposed to learn as a technical handshake. If you understand what “View Transition API” or “flowmap” means, the location is winking at you. That’s the viewers I need to work with.
Results I backed into somewhat than deliberate. Chromatic aberration didn’t begin as a stylistic resolution. It was a check to see how far I might push OGL’s texture sampling. Someplace between the third and fourth iteration, it stopped trying like a tech demo and began trying intentional, so I saved it, and repeated it throughout the video transition and the flowmap hover. It’s change into the visible thread tying the location collectively.
Decreased movement, dealt with correctly. Early variations of this website broke laborious on prefers-reduced-motion: cut back. Cassie Evans’ GSAP talks have been what made me cease treating lowered 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 price.
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
├── kinds/ // 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 element. Lenis has to outlive route adjustments as a result of the scroll place belongs to the doc, not the web page.
Efficiency boils all the way down to 4 habits I utilized in every single place one thing ran each body:
- Direct DOM mutations (
component.model.*,textContent) as a substitute of React state. At 120 Hz, a render tree reconciliation is ~8 ms I don’t have. - Certain, 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 a substitute ofnew Vec2(). - Idle guards: the flowmap’s rAF loop suspends after 90 frames with out enter; the video carousel falls again to native
outdoors transitions. - Construct-time picture metadata:
image-dimensions.json(zero CLS) andlqip-data.json(inline base64 blur-ups), plus per-route chunk preloading on hover.
Reflections
I poured power 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 best way. 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 sector.
What labored
- The persistent flowmap sample: mounting the WebGL element as soon as on the web page root and swapping textures by way of a prop as a substitute 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 lowered 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 laborious
- Safari + View Transitions + clip-path. Safari caches
clip-pathvalues on GPU layers so long as::view-transition-*pseudo-elements are lively. Reset the worth throughout the transition and Safari ignores it till you pressure a repaint. Recognized accidentally, fastened with a 50 ms post-transition buffer and a compelled reflow (void el.offsetHeight). It’s the type of bug you’ll be able to’t discover on Stack Overflow as a result of the best key phrases don’t exist but. - The preload pipeline didn’t converge on the primary attempt. Wanting on the git log,
perf:seems ten occasions in a row over a couple of 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 otherwise
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 belongings dealt with by hand) are precisely what Sanity is designed to resolve. It’s a genuinely distinctive software, and the following iteration of the content material layer will begin there.









