Each designer and developer finally faces the identical problem whereas designing their private portfolio: How do you construct a showcase that doesnβt simply checklist what you do, however truly exhibits it?
For this portfolio, my aim was to seek out the appropriate 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 purpose to create one thing minimalist but revolutionary, a portfolio that might let my work converse for itself whereas showcasing my creativity.
Design & Inspirations
Not like most net initiatives, and since I used to be engaged on each the design and growth myself, I didnβt watch for 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.
Residence web page
The house web page was probably the most tough to design. I went by a number of levels, examined many layouts and developed totally different navigation choices earlier than arising with the ultimate end result as a result of I actually needed the house web page to be impactful whereas serving its function 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 linked the slider to a number of inputs: up/down scroll, left/proper keyboard keys, bullet clicks, and eventually the one Iβve performed probably the most with, dragging over the fluid impact.
Venture pages
Together with the house web page, the undertaking pages are a very powerful a part of a portfolio. With these, I needed to showcase my initiatives in the absolute best means, particularly since a few of them are not obtainable on-line.
Thatβs why I intentionally went for a minimalist and uncluttered format with beneficiant white area and impartial colours, though I enhanced this shade palette with an accent shade that’s totally different for every undertaking, giving every case research 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 at all times 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, many 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 mechanically register them because of Viteβs Glob Import characteristic.
// ~/results/index.ts
export default import.meta.glob('~/results/*.impact.{js,ts}', {
keen: true,
import: 'default',
base: '/results/'
});
// ~/plugins/gsap.shopper.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 fundamental results that wrap a single tween on a given goal: fade, colorize, scaleβ¦
If Iβm being fully sincere, many of the fundamental results had already been created for earlier initiatives earlier than this one even began, however I assume thatβs the purpose of writing reusable code.
const colorizeEffect: GsapEffect = {
title: 'colorize',
impact: (goal, { autoClear, ...config } = { shade: '' }) => {
if (autoClear) {
config.clearProps = ['color', config.clearProps].filter(Boolean).be a 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 complicated results to create extra superior animations.
For instance, the fadeColor impact is a mix of two fundamental results into one which fades in a goal whereas animating its shade. As soon as created, I may animate texts with this fade/shade impact anyplace throughout the web site.
const fadeColorEffect: GsapEffect = {
title: 'fadeColor',
impact: (
goal,
{ autoAlpha, length, stagger, ease, onStart, onComplete, onUpdate, autoClear, ...config } = {}
) => {
const currentColor = gsap.getProperty(goal, 'shade');
const shade = config.shade;
const tl = gsap.timeline({ onStart, onComplete, onUpdate });
tl.set(goal, { shade }, 0);
tl.fadeIn(goal, { autoAlpha, length, stagger, ease, autoClear }, 0);
tl.colorize(goal, { shade: 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 element of the web site could possibly 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 inside the provided perform, scopes all selector textual content to a specific Ingredient or Ref and helps reactive dependencies.
export perform useGSAP(
callback: gsap.ContextFunc,
$scope?: MaybeRefOrGetter,
dependencies: Array> = [],
{ revertOnUpdate = false }: GsapParameters = {}
) {
let context: gsap.Context;
perform 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 loads of break up texts, I additionally added a method to create revert capabilities in my elements and name them from the composable itself.
export perform useScrollAnimateIn(
animateIn: () => gsap.core.Timeline,
$scope: MaybeRefOrGetter,
{ 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.information?.revert?.();
};
},
$scope,
dependencies,
{ revertOnUpdate: true, ...params }
);
}
Because of this composable, writing scroll-triggered animations grew to become fully targeted on the animations themselves, permitting me to shortly and simply create animate-in timelines with reversible break up texts.
In observe, it seems like this:
useScrollAnimateIn(
() => {
const textual content = new SplitText('.textual content', { sort: 'chars' });
const tl = gsap.timeline({
information: {
revert: () => {
textual content.revert();
}
}
});
tl.fadeColor(
textual content.chars,
{
shade: '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 middle' } }
);
Past being a greatest observe, reverting break up texts grew to become strictly obligatory since useScrollAnimateIn re-runs the callback every time a reactive dependency adjustments. With out reverting first, every re-run would name SplitText on an already-split component, nesting components inside components and breaking each the animation and the DOM. It additionally helps maintain the DOM dimension down, splitting a paragraph into chars can simply generate lots 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} vital 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 a substitute of calling GSAP SplitText straight in every element, I created a useSplitText composable and a corresponding element that integrates SplitTextβs performance with Vueβs lifecycle and reactivity.
perform useSplitText(
$component: MaybeRefOrGetter,
{ autoSplit = true, onSplit: onSplitCallback, onRevert: onRevertCallback, ...params }: SplitText.Vars
) {
const $splitText = shallowRef(null);
const $components = ref([]);
const $chars = ref([]);
const $phrases = ref([]);
const $traces = ref([]);
const $masks = ref([]);
const isSplit = ref(false);
perform createSplitText(): SplitTextInstance | null {
const $el = toValue($component);
if ($el) {
return new SplitText($el, { autoSplit, onSplit, onRevert, ...params });
}
return null;
}
perform onSplit(splitText: SplitTextInstance) {
$components.worth = splitText.components ?? [];
$chars.worth = splitText.chars ?? [];
$phrases.worth = splitText.phrases ?? [];
$traces.worth = splitText.traces ?? [];
$masks.worth = splitText.masks ?? [];
isSplit.worth = splitText.isSplit ?? true;
onSplitCallback?.(splitText);
return splitText;
}
perform onRevert(splitText: SplitTextInstance) {
$components.worth = splitText.components ?? [];
$chars.worth = splitText.chars ?? [];
$phrases.worth = splitText.phrases ?? [];
$traces.worth = splitText.traces ?? [];
$masks.worth = splitText.masks ?? [];
isSplit.worth = splitText.isSplit ?? false;
onRevertCallback?.(splitText);
return splitText;
}
perform break up() {
return $splitText.worth?.break up() ?? null;
}
perform revert() {
return $splitText.worth?.revert() ?? null;
}
onMounted(() => {
$splitText.worth = createSplitText();
});
return {
$components: shallowReadonly($components),
$chars: shallowReadonly($chars),
$phrases: shallowReadonly($phrases),
$traces: shallowReadonly($traces),
$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 following relatively than merely fading in and out.
I broke down the animation as follows:
- Determine the characters from the earlier string that additionally seem within the subsequent one (theΒ leftovers)
- Hold theΒ leftovers seen
- Cover the remaining earlier characters
- Animate theΒ leftovers to their corresponding positions within the subsequent string
- Present the following characters and conceal the earlier ones as soon as each character is in place
So with the intention to reproduce this textual content transition in a reusable and reactive means (youβre seeing it coming), I created a element that manages the matching algorithm, watches its props to find out which textual content must be at the moment displayed and triggers the transition. Internally, it creates one element per textual content merchandise acquired as props and makes use of them to transition between these textual content on props adjustments.
sort Leftover = {
nextIndex: quantity;
previousIndex: quantity;
char: string;
delta: quantity;
};
sort Leftovers = Map;
perform 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)
.type((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Β element 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 interested by 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 won’t be the final, thatβs why I made a decision to speculate a while to create a well-structured, reusable FluidSimulation helper class particularly devoted to this function.
The fluid simulation was constructed ranging from OGLβs Put up 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 system help
βββ applications/
β βββ glsl/
β β βββ base.vert
β β βββ fluid.vert
β β βββ advection-manual-filtering.frag
β β βββ advection.frag
β β βββ curl.frag
β β βββ dissipation.frag
β β βββ divergence.frag
β β βββ gradient-subtract.frag
β β βββ strain.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 delicate however necessary element concerned the dissipation parameter controlling how a lot information a render goal retains from one body to the following. A dissipation of 0 fully clears the buffer each body, whereas a worth of 1 means the info by no means fades out.
uniform sampler2D inputBuffer;
uniform float dissipation;
various vec2 vUv;
void major() {
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 price varies: on a 120fps show, this multiplier is utilized twice as usually as on a 60fps show, leading to a totally totally different feeling on totally different {hardware}.
To repair this subject, 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 system refresh price
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 all types 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 major() {
// Retrieve density buffer information
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 information + 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 strategy of sharing a renderer context by a present/inject sample. This led to 2 core elements: and . Principally, R3F logic delivered to a Vue + OGL context.
The element creates the DOM canvas component whereas the element receives the canvas as a prop, instantiates the OGL renderer, and shares it with any little one element.
Final step, I created a element liable for retrieving the OGL renderer from the , 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 element with the specified parameters inside an .
Regardless that they finally didnβt make the reduce, having a reusable element for the WebGL fluid impact allowed me to simply run some assessments, reminiscent of reusing it with totally different parameters within the undertaking pages footer.
Accessibility
Regardless of what some would possibly assume, accessibility and creativity are usually not mutually unique. Listed here are a couple of 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 reader
Past making an attempt to setting the appropriate aria-label / aria-hidden attributes wherever I believed they have been wanted, I examined them utilizing an precise display reader relatively than counting on automated accessibility instruments alone. Automated instruments could be helpful however they donβt essentially catch the complete image, so listening to how a display reader truly navigates by 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.
Accessible animation: Diminished movement preferences
Regardless that animation is the half I get pleasure from probably 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 observe of this consumer desire.
const reducedMotion = ref(false);
const reducedMotionMediaQuery = '(prefers-reduced-motion: scale back)';
perform onReducedMotionChange() {
reducedMotion.worth = window.matchMedia(reducedMotionMediaQuery).matches;
}
window.matchMedia(reducedMotionMediaQuery).addEventListener('change', onReducedMotionChange);
onReducedMotionChange();
I then used this Ref to interchange 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 includes 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 by way of Nuxt SSG, a couple of CSS guidelines wrapped in 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, scripting this case research wasnβt a straightforward job, I had a tough time deciding which matter to cowl and extracting probably the most fascinating code snippets whereas preserving issues concise and clear. I attempted to make this behind-the-scenes look as fascinating as potential for everybody: from junior builders to senior ones. So I hope you loved studying it!









