19.7 C
New York
Tuesday, March 31, 2026

Arnaud Rocca’s Portfolio: From a GSAP-Powered Movement System to Fluid WebGL



Each designer and developer finally faces the identical problem whereas designing their private portfolio: How do you construct a showcase that doesn’t simply record what you do, however truly exhibits it?

For this portfolio, my purpose was to search out the correct steadiness between showcasing my work and constructing one thing reflective of what I do as a artistic developer. With that in thoughts, I began designing my portfolio with the intention to create one thing minimalist but modern, a portfolio that will let my work converse for itself whereas showcasing my creativity.

Design & Inspirations

Not like most net tasks, and since I used to be engaged on each the design and growth myself, I didn’t look ahead to the design to be completed earlier than kicking off growth. Each moved ahead in parallel, which allowed me to completely discover my creativity, with concepts coming from either side.

House web page

The house web page was essentially the most tough to design. I went by way of a number of levels, examined many layouts and developed totally different navigation choices earlier than arising with the ultimate consequence as a result of I actually needed the house web page to be impactful whereas serving its objective as the web site’s entry level.

Its navigation is considerably impressed by Instagram tales, with autoplaying slides. However to make it extra user-friendly, I additionally related the slider to a number of inputs: up/down scroll, left/proper keyboard keys, bullet clicks, and eventually the one I’ve performed essentially the most with, dragging over the fluid impact.

Venture pages

Together with the house web page, the undertaking pages are crucial a part of a portfolio. With these, I needed to showcase my tasks in the very best manner, particularly since a few of them are not accessible on-line.

That’s why I intentionally went for a minimalist and uncluttered structure with beneficiant white house and impartial colours, though I enhanced this colour palette with an accent colour that’s totally different for every undertaking, giving every case examine its personal distinctive temper.

Technical stack

  • Nuxt: Frontend framework
  • GSAP: Animation library
  • Lenis: Easy scroll library
  • OGL: Minimal WebGL library
  • Prismic: Headless CMS
  • Figma: Design software

Whatever the frameworks and libraries used, one among my pointers as a developer is all the time to provide clear reusable code. This utilized to each piece of code on this undertaking and has influenced my technical selections whereas creating elements, composables, utility capabilities, animations, WebGL results…

UI animations

GSAP Results: Creating reusable animations

With reusability as a core precept, a lot of the animations have been constructed utilizing GSAP results. All results have been positioned in a devoted folder and adopted a constant naming conference, permitting me to robotically register them due to Vite’s Glob Import function.

// ~/results/index.ts

export default import.meta.glob('~/results/*.impact.{js,ts}', {
	keen: true,
	import: 'default',
	base: '/results/'
});
// ~/plugins/gsap.consumer.ts

import results from '~/results';

export default defineNuxtPlugin(() => {
	// Register plugins
	// [...]

	// Register results
	Object.values(results).forEach((impact) => gsap.registerEffect(impact));
});

I began by creating some primary results that wrap a single tween on a given goal: fade, colorize, scale

If I’m being fully sincere, a lot of the primary results had already been created for earlier tasks earlier than this one even began, however I suppose that’s the purpose of writing reusable code.

const colorizeEffect: GsapEffect<ColorizeEffectConfig> = {
	title: 'colorize',
	impact: (goal, { autoClear, ...config } = { colour: '' }) => {
		if (autoClear) {
			config.clearProps = ['color', config.clearProps].filter(Boolean).be part of(',');
		}

		return gsap.to(goal, { ...config });
	},
	defaults: { length: 0.2, ease: 'sine.out' },
	extendTimeline: true
};

After creating the fundamental results, I may add them to timelines or mix them into extra advanced results to create extra superior animations.

For instance, the fadeColor impact is a mixture of two primary results into one which fades in a goal whereas animating its colour. As soon as created, I may animate texts with this fade/colour impact wherever throughout the web site.

const fadeColorEffect: GsapEffect<FadeColorEffectConfig> = {
	title: 'fadeColor',
	impact: (
		goal,
		{ autoAlpha, length, stagger, ease, onStart, onComplete, onUpdate, autoClear, ...config } = {}
	) => {
		const currentColor = gsap.getProperty(goal, 'colour');
		const colour = config.colour;

		const tl = gsap.timeline({ onStart, onComplete, onUpdate });
		tl.set(goal, { colour }, 0);
		tl.fadeIn(goal, { autoAlpha, length, stagger, ease, autoClear }, 0);
		tl.colorize(goal, { colour: currentColor, length, stagger, ease, autoClear }, config.timeout);
		return tl;
	},
	defaults: { autoAlpha: 1, length: 0.9, timeout: 0.3 },
	extendTimeline: true
};

Thanks to those results, every new part of the web site may very well be animated shortly with constant, examined conduct, with out having to reinvent the wheel for every new part.

GSAP ScrollTrigger x Vue: A system for scroll-based animations

With the results library in place, I wanted a clear method to set off these animations on scroll. I began by making a Vue equal of GSAP’s useGSAP React hook that wraps all GSAP animations and ScrollTriggers which can be created throughout the provided operate, scopes all selector textual content to a specific Ingredient or Ref and helps reactive dependencies.

export operate useGSAP(
	callback: gsap.ContextFunc,
	$scope?: MaybeRefOrGetter<Ingredient | null>,
	dependencies: Array<MaybeRefOrGetter<unknown>> = [],
	{ revertOnUpdate = false }: GsapParameters = {}
) {
	let context: gsap.Context;

	operate replace() {
		if (revertOnUpdate) context?.revert();
		context?.clear();
		context?.add(callback);
	}

	dependencies.forEach((dependency) => {
		if (isRef(dependency)) watch(dependency, replace);
		else watch(() => toValue(dependency), replace);
	});

	onMounted(() => {
		context = gsap.context(callback, toValue($scope) ?? undefined);
	});

	onUnmounted(() => {
		context?.revert();
	});
}

From there, I constructed useScrollAnimateIn: a higher-level composable particularly designed for scroll-triggered animations. Since I knew I’d be animating numerous break up texts, I additionally added a method to create revert capabilities in my elements and name them from the composable itself.

export operate useScrollAnimateIn(
	animateIn: () => gsap.core.Timeline,
	$scope: MaybeRefOrGetter<Ingredient | null>,
	{ scrollTrigger = {}, dependencies = [], ...params }: ScrollAnimateInParameters = {}
) {
	useGSAP(
		() => {
			const animateInTl = animateIn().pause(0);

			ScrollTrigger.create({
				...scrollTrigger,
				set off: scrollTrigger.set off ?? toValue($scope),
				as soon as: true,
				onEnter: (self: ScrollTrigger) => {
					animateInTl.play(0);
					scrollTrigger.onEnter?.(self);
				}
			});

			return () => {
				animateInTl.knowledge?.revert?.();
			};
		},
		$scope,
		dependencies,
		{ revertOnUpdate: true, ...params }
	);
}

Due to this composable, writing scroll-triggered animations turned totally targeted on the animations themselves, permitting me to shortly and simply create animate-in timelines with reversible break up texts.

In apply, it appears to be like like this:

useScrollAnimateIn(
	() => {
		const textual content = new SplitText('.textual content', { kind: 'chars' });

		const tl = gsap.timeline({
			knowledge: {
				revert: () => {
					textual content.revert();
				}
			}
		});

		tl.fadeColor(
			textual content.chars,
			{
				colour: 'var(--color)',
				length: 0.9,
				stagger: { quantity: 0.2 },
				timeout: 0.3,
				autoClear: true,
				onComplete: () => {
					textual content.revert();
				}
			},
			0
		);

		return tl;
	},
	$el,
	{ scrollTrigger: { begin: 'prime heart' } }
);

Past being a greatest apply, reverting break up texts turned strictly obligatory since useScrollAnimateIn re-runs the callback every time a reactive dependency modifications. With out reverting first, every re-run would name SplitText on an already-split aspect, nesting components inside components and breaking each the animation and the DOM. It additionally helps hold the DOM dimension down, splitting a paragraph into chars can simply generate tons of of additional nodes, so cleansing them up after use is a significant efficiency consideration.

GSAP SplitText x Vue: Implementing reactive textual content animations

Given {that a} important quantity of the web site’s animations depends on textual content results, I made a decision to create a strong basis for implementing them. So as an alternative of calling GSAP SplitText straight in every part, I created a useSplitText composable and a corresponding <SplitText> part that integrates SplitText’s performance with Vue’s lifecycle and reactivity.

operate useSplitText(
	$aspect: MaybeRefOrGetter<Ingredient | null>,
	{ autoSplit = true, onSplit: onSplitCallback, onRevert: onRevertCallback, ...params }: SplitText.Vars
) {
	const $splitText = shallowRef<SplitTextInstance | null>(null);

	const $components = ref<Ingredient[]>([]);
	const $chars = ref<Ingredient[]>([]);
	const $phrases = ref<Ingredient[]>([]);
	const $strains = ref<Ingredient[]>([]);
	const $masks = ref<Ingredient[]>([]);

	const isSplit = ref<boolean>(false);

	operate createSplitText(): SplitTextInstance | null {
		const $el = toValue($aspect);
		if ($el) {
			return new SplitText($el, { autoSplit, onSplit, onRevert, ...params });
		}
		return null;
	}

	operate onSplit(splitText: SplitTextInstance) {
		$components.worth = splitText.components ?? [];
		$chars.worth = splitText.chars ?? [];
		$phrases.worth = splitText.phrases ?? [];
		$strains.worth = splitText.strains ?? [];
		$masks.worth = splitText.masks ?? [];
		isSplit.worth = splitText.isSplit ?? true;
		onSplitCallback?.(splitText);
		return splitText;
	}

	operate onRevert(splitText: SplitTextInstance) {
		$components.worth = splitText.components ?? [];
		$chars.worth = splitText.chars ?? [];
		$phrases.worth = splitText.phrases ?? [];
		$strains.worth = splitText.strains ?? [];
		$masks.worth = splitText.masks ?? [];
		isSplit.worth = splitText.isSplit ?? false;
		onRevertCallback?.(splitText);
		return splitText;
	}

	operate break up() {
		return $splitText.worth?.break up() ?? null;
	}

	operate revert() {
		return $splitText.worth?.revert() ?? null;
	}

	onMounted(() => {
		$splitText.worth = createSplitText();
	});

	return {
		$components: shallowReadonly($components),
		$chars: shallowReadonly($chars),
		$phrases: shallowReadonly($phrases),
		$strains: shallowReadonly($strains),
		$masks: shallowReadonly($masks),
		isSplit: readonly(isSplit),
		break up,
		revert
	};
}

The Leftovers” textual content transition

The house web page textual content transition is one thing I’ve needed to breed since I watched “The Leftovers”, particularly these few seconds on the finish of the present’s opening title the place characters transition from one title to the subsequent moderately than merely fading in and out.

I broke down the animation as follows:

  1. Determine the characters from the earlier string that additionally seem within the subsequent one (the leftovers)
  2. Maintain the leftovers seen
  3. Cover the remaining earlier characters
  4. Animate the leftovers to their corresponding positions within the subsequent string
  5. Present the subsequent characters and conceal the earlier ones as soon as each character is in place

So so as to reproduce this textual content transition in a reusable and reactive manner (you’re seeing it coming), I created a <LeftoversText> part that manages the matching algorithm, watches its props to find out which textual content ought to be at present displayed and triggers the transition. Internally, it creates one <SplitText> part per textual content merchandise acquired as props and makes use of them to transition between these textual content on props modifications.

kind Leftover = {
	nextIndex: quantity;
	previousIndex: quantity;
	char: string;
	delta: quantity;
};

kind Leftovers = Map<quantity, Leftover>;

operate getLeftovers(
	$nextText: SplitTextComponentInstance | null,
	$previousText: SplitTextComponentInstance | null
): Leftovers {
	const $nextChars = $nextText?.$chars ?? [];
	const $previousChars = $previousText?.$chars ?? [];
	const nextChars = $nextChars.map(($char) => $char.textContent);
	const previousChars = $previousChars.map(($char) => $char.textContent);

	const leftovers: Leftovers = new Map();

	previousChars.forEach((previousChar, previousIndex) => {
		const match = nextChars
			.map((nextChar, nextIndex) => ({
				index: nextIndex,
				char: nextChar,
				delta: Math.abs(nextIndex - previousIndex)
			}))
			.filter(({ char }) => char === previousChar)
			.kind((a, b) => a.delta - b.delta)
			.at(0);

		if (match) {
			const leftover = leftovers.get(match.index);
			if (!leftover || match.delta < leftover.delta) {
				leftovers.set(match.index, {
					nextIndex: match.index,
					previousIndex,
					char: match.char,
					delta: match.delta
				});
			}
		}
	});

	return leftovers;
}

Voilà! After tweaking the durations, delays and eases, I had a reusable and reactive <LeftoversText> part prepared for use.

WebGL results

OGL: A small however efficient WebGL library

OGL describes itself as “a small, efficient WebGL library aimed toward builders who like minimal layers of abstraction, and are involved in creating their very own shaders”.

Precisely what I wanted!

WebGL fluid simulation

It wasn’t my first time enjoying with WebGL fluid simulation and it may not be the final, that’s why I made a decision to take a position a while to create a well-structured, reusable FluidSimulation helper class particularly devoted to this objective.

The fluid simulation was constructed ranging from OGL’s Publish Fluid Distortion instance, which I then refactored into the next structure:

webgl/
├── helpers/
│   ├── FluidSimulation.ts 	# Helper class to render fluid simulation
│   ├── RenderTargets.ts 	# Helper class to create ping-pong RenderTargets
├── utils/
│   ├── helps.ts 		# Utility capabilities for bigger gadget assist
├── packages/
│   ├── glsl/
│   │   ├── base.vert
│   │   ├── fluid.vert
│   │   ├── advection-manual-filtering.frag
│   │   ├── advection.frag
│   │   ├── curl.frag
│   │   ├── dissipation.frag
│   │   ├── divergence.frag
│   │   ├── gradient-subtract.frag
│   │   ├── stress.frag
│   │   ├── splat.frag
│   │   ├── vorticity.frag
│   ├── AdvectionProgram.ts
│   ├── CurlProgram.ts
│   ├── DissipationProgram.ts
│   ├── DivergenceProgram.ts
│   ├── GradientSubtractProgram.ts
│   ├── PressureProgram.ts
│   ├── SplatProgram.ts
│   ├── VorticityProgram.ts

Body-rate impartial simulation

One refined however essential element concerned the dissipation parameter controlling how a lot knowledge a render goal retains from one body to the subsequent. A dissipation of 0 fully clears the buffer each body, whereas a worth of 1 means the information by no means fades out.

uniform sampler2D inputBuffer;
uniform float dissipation;

various vec2 vUv;

void primary() {
	gl_FragColor = dissipation * texture2D(inputBuffer, vUv);
}

In OGL’s instance, the dissipation parameter retains the identical fixed worth each body. Sadly this breaks on high-refresh-rate shows or when body charge varies: on a 120fps show, this multiplier is utilized twice as usually as on a 60fps show, leading to a very totally different feeling on totally different {hardware}.

To repair this difficulty, I built-in deltaTime into the calculation, normalizing the dissipation to be frame-rate impartial:

// Earlier than
program.uniforms.dissipation.worth = config.dissipation;

// After - Normalize to 60fps so conduct is constant no matter gadget refresh charge
program.uniforms.dissipation.worth = Math.pow(config.dissipation, deltaTime * 60);

Right here’s a demo of how the FluidSimulation helper can be utilized to render every kind of results involving fluid simulations:

WebGL fluid slider

As soon as the FluidSimulation helper class was created, I tackled the implementation of the FluidSliderEffect: a category that runs the fluid simulation and makes use of the density render goal into its personal fragment shader, compositing the fluid movement as a distortion + masks impact over the slider textures.

uniform sampler2D densityBuffer; // FluidSimulation density buffer
uniform sampler2D maps[COUNT]; // Slider textures
uniform int foregroundIndex;
uniform int backgroundIndex;
uniform float foregroundProgress;
uniform float backgroundProgress;
uniform vec2 foregroundDisplacement;
uniform vec2 backgroundDisplacement;

various vec2 vUv;

void primary() {
	// Retrieve density buffer knowledge
	vec4 density = texture2D(densityBuffer, vUv);
	// Compute distortion vector	
	vec2 distortion = -density.rg;
	// Compute normalized masks worth
	float masks = clamp((abs(density.r) + abs(density.g)) / 2.0, 0.0, 1.0);

	// Retrieve textures knowledge + Apply distortion
	vec4 foregroundMap = texture2D(maps[foregroundIndex], vUv + distortion * foregroundDisplacement);
	vec4 backgroundMap = texture2D(maps[backgroundIndex], vUv + distortion * backgroundDisplacement);

	// Composite textures + Apply masks
	vec4 foreground = combine(backgroundMap, foregroundMap, foregroundProgress);
	vec4 background = combine(foregroundMap, backgroundMap, backgroundProgress);
	vec4 map = combine(foreground, background, masks);

	gl_FragColor.rgb = map.rgb;
}

OGL x Vue: Constructing reusable and reactive WebGL elements

For structuring the WebGL layer, I took direct inspiration from React Three Fiber / TresJS and their method of sharing a renderer context by way of a present/inject sample. This led to 2 core elements: <OglCanvas> and <OglContext>. Principally, R3F logic delivered to a Vue + OGL context.

The <OglCanvas> part creates the DOM canvas aspect whereas the <OglContext> part receives the canvas as a prop, instantiates the OGL renderer, and shares it with any baby part.

<!-- OglCanvas.vue -->

<canvas ref="$canvas" :class="$attrs.class">
	<OglContext v-if="$canvas" v-bind="props" ref="$context" :canvas="$canvas">
		<slot />
	</OglContext>
</canvas>

Final step, I created a <FluidSlider> part accountable for retrieving the OGL renderer from the <OglContext>, managing a FluidSliderEffect occasion, listening to mouse occasions and reactively updating the impact occasion on props change.

Then, all I needed to do was use this <FluidSlider> part with the specified parameters inside an <OglCanvas>.

<OglCanvas>
	<FluidSlider
		:objects="objects"
		:index="currentIndex"
		:displacement="0.0005"
		:density-dissipation="isPressed ? 0.95 : 0.9"
		:velocity-dissipation="0.985"
		:pressure-dissipation="0.8"
		:curl-strength="8"
		:radius="isPressed ? 0.075 : 0.05"
		:sensitivity="25"
	/>
</OglCanvas>

Despite the fact that they finally didn’t make the lower, having a reusable part for the WebGL fluid impact allowed me to simply run some exams, equivalent to reusing it with totally different parameters within the undertaking pages footer.

Accessibility

Regardless of what some may suppose, accessibility and creativity aren’t mutually unique. Listed below are just a few steps I took to make this portfolio as accessible as potential with out sacrificing any of the artistic work for many customers.

ARIA attributes: Use of an precise display screen reader

Past attempting to setting the correct aria-label / aria-hidden attributes wherever I believed they have been wanted, I examined them utilizing an precise display screen reader moderately than counting on automated accessibility instruments alone. Automated instruments may be helpful however they don’t essentially catch the total image, so listening to how a display screen reader truly navigates by way of the pages helped me establish accessibility points that I may simply repair afterward.

Keyboard navigation: blur / focus handlers

To make sure keyboard customers get the identical expertise and animations as mouse customers, along with set the proper tabindex attributes and binding some keyboard keys, most CTAs mouseenter / mouseleave handlers have been paired with their focus / blur equivalents, making certain a constant expertise for all customers.

<template>
	<button 
		@mouseenter="handleEnter"
		@mouseleave="handleLeave"
		@focus="handleEnter"
		@blur="handleLeave"
	/>
</template>

<script lang="ts" setup>
operate handleEnter() {
	// Enter animations
	// [...]
}

operate handleLeave() {
	// Go away animations
	// [...]
}
</script>

Accessible animation: Diminished movement preferences

Despite the fact that animation is the half I take pleasure in essentially the most, I needed to respect customers’ preferences. So I added a listener to the prefers-reduced-motion media question, updating a reducedMotion Ref to maintain monitor of this person desire.

const reducedMotion = ref<boolean>(false);
const reducedMotionMediaQuery = '(prefers-reduced-motion: scale back)';

operate onReducedMotionChange() {
	reducedMotion.worth = window.matchMedia(reducedMotionMediaQuery).matches;
}

window.matchMedia(reducedMotionMediaQuery).addEventListener('change', onReducedMotionChange);
onReducedMotionChange();

I then used this Ref to exchange the house web page WebGL impact with a easy cross-fade between thumbnails and wrapped all scroll-based animations in gsap.matchMedia() circumstances utilizing the identical media question.

No JavaScript, No drawback

As a artistic developer, most of my work entails JavaScript/TypeScript code, nevertheless I needed to push accessibility even additional by making certain that the content material remained accessible to customers with JavaScript disabled.

Since all pages have been already served as static recordsdata through Nuxt SSG, just a few CSS guidelines wrapped in <noscript> tags have been sufficient to make sure the content material remained accessible with out JavaScript, leading to a content-focused model of the web site: no animations, no transitions, no WebGL, simply pure HTML/CSS.

Thanks for studying

To be sincere, penning this case examine wasn’t a straightforward activity, I had a tough time deciding which matter to cowl and extracting essentially the most attention-grabbing code snippets whereas holding issues concise and clear. I attempted to make this behind-the-scenes look as attention-grabbing as potential for everybody: from junior builders to senior ones. So I hope you loved studying it!



Supply hyperlink

Related Articles

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Latest Articles