Free course suggestion: Grasp JavaScript animation with GSAP via 34 free video classes, step-by-step tasks, and hands-on demos. Enroll now →
This case research walks via the whole inventive course of I went via whereas constructing my web site.
It’s organized into 5 chronological steps, every representing a key stage within the venture’s improvement and the choices that formed the ultimate outcome. You’ll see how what I initially thought can be the centerpiece nearly turned non-compulsory, whereas a number of options disappeared totally as a result of they didn’t match the visible route that emerged.
Probably the most thrilling a part of this course of was watching issues reveal themselves organically, guiding the inventive journey fairly than dictating it from the beginning. I needed to keep affected person and attentive to grasp what ambiance was taking form in entrance of me.
I recorded movies alongside the way in which to remind myself, on troublesome days, the place I got here from and to understand the evolution. I need to be clear and share these captures with you, regardless that they’re fully work-in-progress and much from polished. I discover it extra fascinating to see how issues evolve.
All through this case research, I’ll additionally share some insights and sensible tips about how I used GSAP, particularly for the MorphSVG plugin.
Tech Stack:
- Subsequent.js
- Three.js / React Three Fiber
- GSAP
1. The Fold Impact: The place It All Began
At first, I needed so as to add a WebGL fold impact as a result of it was one thing I’d tried to grasp throughout my first mission 5 years in the past with Not possible Bureau however couldn’t handle effectively. I remembered there was already a case research by Davide Perozzi on Codrops who did an amazing walkthrough to elucidate the impact, so based mostly on that, I replicated the impact and added the flexibility to fold alongside each axes.
The Answer: Vector Projection
To make the fold occur in any route, we’ve got to use vector projection to redistribute the curl impact alongside an arbitrary route:
// 1. Normalize the route
vec2 dir = normalize(uDirection);
// 2. Mission the vertex onto the route axis
float projValue = dot(vec2(place.xy), dir);
// 3. Apply the curve perform to this projection
vec2 curledPosition = curlPlane(projValue, effectiveSize, uCurlX, uCurlY, true);
// 4. Redistribute the outcome within the chosen route
newposition.xy += dir * (curledPosition.x - projValue);
newposition.z += curledPosition.y;
The curlPlane perform transforms a linear place right into a curved place utilizing round arc arithmetic. By projecting every vertex place onto the specified fold route utilizing the dot product, making use of the curl perform to that one-dimensional worth, after which redistributing the outcome again alongside the route vector, the impact gained full directional freedom.
Including Pretend Shadow
To make the fold impact extra real looking, I added a refined faux shadow based mostly on the curvature quantity. The thought is straightforward: the extra the floor curls, the darker it turns into.
Within the vertex shader, I calculate how a lot the floor is curving:
// Calculate curvature for shadow (0 = flat, 1 = most curl)
float maxExpectedCurl = 0.5;
vCurvatureAmount = smoothstep(0.0, maxExpectedCurl, abs(curledPosition.y));
Then within the fragment shader, I apply a shadow based mostly on this curvature:
various float vCurvatureAmount;
void major() {
vec4 colour = gl_FrontFacing
? texture(uTexture, vUv)
: texture(uBackTexture, vUv);
// Apply faux shadow based mostly on curvature
float shadow = 1.0 - vCurvatureAmount * 0.25;
colour.rgb *= shadow;
gl_FragColor = vec4(colour.rgb, colour.a);
}
This easy method provides a refined sense of depth with out requiring advanced lighting calculations, making the fold really feel extra three-dimensional and bodily grounded.
And tada! I had my sticker fold impact:
That is how I assumed I’d have my first major function within the portfolio, however at this level, I didn’t know I used to be unsuitable and the journey was simply getting began…
2. A Display screen Seems with a Character Inside
Whereas enjoying with this new fold impact for various layouts and animations on my homepage, I additionally constructed a diffraction impact on textual content (which finally didn’t make it into the ultimate portfolio, I could write a separate tutorial about it). As I experimented with these two results, I immediately needed to make a display screen seem within the heart. And I nonetheless don’t know precisely why, however I additionally needed a personality inside.
For a very long time, I’d needed to play with 3D characters, bones, and animations. That is how my 3D character first appeared within the journey. Initially, I needed to make him discuss, so I added subtitles and completely different actions. The character and animations got here from Mixamo.
MeshPortal: Rendering a Bounded Scene
For embedding the 3D scene inside a bounded space, I used the MeshPortal method. As a substitute of rendering to the total canvas, I created a separate scene that renders to a render goal (FBO), then displayed that texture on a airplane mesh with a customized masks shader.
Right here’s the core setup:
// Create separate scene and digicam for the portal
const otherSceneRef = useRef(new THREE.Scene());
const otherCameraRef = useRef();
// Create render goal (Body Buffer Object)
const renderTarget = useFBO({
width: viewport.width * viewport.dpr,
top: viewport.top * viewport.dpr,
});
// Render the portal scene to the feel
useFrame((state) => {
const { gl } = state;
// Render portal scene to render goal
if (composer) composer.render();
// Apply the rendered texture to the portal mesh
if (portalMeshRef.present && portalMeshRef.present.materials) {
portalMeshRef.present.materials.uniforms.tDiffuse.worth = renderTarget.texture;
}
});
// The portal mesh shows the rendered scene
<mesh ref={portalMeshRef}>
<planeGeometry args={[viewport.width, viewport.height]} />
<shaderMaterial
uniforms={uniforms}
vertexShader={portalVertexShader}
fragmentShader={portalFragmentShader}
/>
</mesh>
The portal shader makes use of a uMask uniform (a vec4 representing left, proper, backside, prime bounds) to clip the rendered texture, creating that exact “display screen inside a display screen” impact:
uniform vec4 uMask; // x1, x2, y1, y2
void major() {
vec2 uv = vUv;
// Clip based mostly on masks bounds
if (uv.x < uMask.x || uv.x > uMask.y ||
uv.y < uMask.z || uv.y > uMask.w) {
discard;
}
vec4 colour = texture2D(tDiffuse, uv);
gl_FragColor = colour;
}
Constructing a System to Make the Portal Responsive Between Sections
Whereas enjoying with this setup, I spotted it might be fascinating to animate the display screen between part transitions, altering its placement, dimension, and the 3D scene (digicam place, character actions, dice dimension…).
However this required fixing three related challenges:
- A. Monitor portal positions – sync WebGL with DOM structure
- B. Deal with navigation – easy transitions between sections
- C. Animate all the pieces – orchestrate advanced animations with GSAP
Let me stroll you thru how I constructed every bit.
A. Syncing WebGL Portal with DOM Bounds
The Problem: The portal wanted to adapt to completely different positions and sizes on every web page whereas staying responsive.
The Answer: I created a system that tracks DOM ingredient bounds and converts them to WebGL coordinates. In every part, I positioned a reference <div> that defines the place the portal needs to be:
<div ref={redSquareRef} />
Then I created a hook to trace this div’s bounds and normalize them to viewport coordinates (0-1 vary):
// Normalize DOM bounds to viewport coordinates (0-1 vary)
export perform normalizeBounds(bounds, dimensions) {
return {
x: bounds.x / dimensions.width,
y: bounds.y / dimensions.top,
width: bounds.width / dimensions.width,
top: bounds.top / dimensions.top,
};
}
// Monitor the reference div and register its bounds for every part
useResizeObserver(() => {
const dimensions = { width: window.innerWidth, top: window.innerHeight };
const bounds = redSquareRef.present.getBoundingClientRect();
const normalizedBounds = normalizeBounds(bounds, dimensions);
setRedSquareSize(sectionName, {
x: normalizedBounds.x,
y: normalizedBounds.y,
width: normalizedBounds.width,
top: normalizedBounds.top,
});
});
These normalized bounds are then transformed to match the shader’s vec4 uMask format (left, proper, backside, prime):
perform calculateMaskValues(dimension) {
return {
x: dimension.x, // left
y: dimension.width + dimension.x, // proper
z: 1 - (dimension.top + dimension.y), // backside
w: 1 - dimension.y, // prime
};
}
B. Hash-Primarily based Part Navigation with Clean Transitions
Now that I might observe portal positions, I wanted a method to navigate between sections easily.
Since my portfolio has just a few major areas (Residence, Initiatives, About, Contact), I made a decision to construction them as sections fairly than separate pages, utilizing hash-based routing to navigate between them.
Why hash-based navigation?
- Retains the whole expertise in a single web page load
- Permits easy crossfade transitions between sections
- Maintains browser historical past for again/ahead navigation
- Stays accessible with correct URL states
The Setup: Every part has a singular id attribute that corresponds to its hash route:
<part id="residence">...</part>
<part id="tasks">...</part>
<part id="about">...</part>
<part id="contact">...</part>
The useSectionTransition hook handles this routing whereas orchestrating simultaneous in/out animations:
export perform useSectionTransition({ onEnter, onExiting, information } = {}) {
const ref = useRef(null);
const { currentSection, isLoaded } = useStore();
const prevSectionRef = useRef(currentSection);
const hasEnteredOnce = useRef(false);
useEffect(() => {
if (!isLoaded) return;
const sectionId = ref.present?.id;
if (!sectionId) return;
const isCurrent = currentSection === sectionId;
const wasCurrent = prevSectionRef.present === sectionId;
// Transition in
if (isCurrent && !wasCurrent && hasEnteredOnce.present) {
onEnter?.({ from: prevSectionRef.present, to: currentSection, information });
ref.present.fashion.pointerEvents = "auto";
}
// Transition out (simultaneous, non-blocking)
if (!isCurrent && wasCurrent) {
onExiting?.({ from: sectionId, to: currentSection, finished: () => {}, information });
ref.present.fashion.pointerEvents = "none";
}
prevSectionRef.present = currentSection;
}, [currentSection, isLoaded]);
return ref;
}
The way it works: Once you navigate from part A to B, part A’s onExiting callback fires instantly whereas part B’s onEnter fires on the similar time, creating easy crossfaded transitions.
The hash adjustments are pushed to the browser historical past, so the again/ahead buttons work as anticipated, retaining the navigation accessible and in step with customary internet conduct.
C. Bringing It All Collectively: Animating the Portal
With each the bounds monitoring system and part navigation in place, animating the portal between sections turned easy:
updatePortal = (dimension, length = 1, ease = "power3.out", delay = 0) => {
const maskValues = calculateMaskValues(dimension);
gsap.to(portalMeshRef.present.materials.uniforms.uMask.worth, {
...maskValues,
length,
ease,
delay,
});
};
// In my part transition callbacks:
onEnter: () => {
updatePortal(redSquareSize.about, 1.2, "expo.inOut");
}
This method permits the portal to seamlessly transition between completely different sizes and positions on every part whereas staying completely attentive to window resizing.
The End result
As soon as I had this method in place, it turned simple to experiment with transitions and discover the appropriate layouts for every web page. Via testing, I eliminated the subtitles and stored a void ambiance with simply the character alone in a giant house, animating just a few parts to make it really feel stranger and extra contemplative. I might even experiment with a second portal for break up display screen results (which you’ll see didn’t keep…)
3. Let’s Make Issues Dance
Whereas testing completely different character actions, I lastly determined to make the character dance all through the navigation. This gave me the concept to create dynamic movement results to accompany this dance.
For the Initiatives web page, I needed one thing easy that highlights the purchasers I’ve labored with and provides an natural scroll impact. By rendering venture titles as WebGL textures, I experimented with a number of parameters and rapidly created this easy but dynamic stretch impact that responds to scroll velocity.
Velocity-Primarily based Stretch Shader
The vertex shader creates a sine-wave distortion based mostly on scroll velocity:
uniform vec2 uViewportSizes;
uniform float uVelocity;
uniform float uScaleY;
void major() {
vec3 newPosition = place;
newPosition.x *= uScaleX;
vec4 finalPosition = modelViewMatrix * vec4(newPosition, 1.0);
// Calculate stretch based mostly on place in viewport
float ampStretch = 0.009 * uScaleY;
float M_PI = 3.1415926535897932;
vProgressVisible = sin(finalPosition.y / uViewportSizes.y * M_PI + M_PI / 2.0)
* abs(uVelocity * ampStretch);
// Apply vertical stretch
finalPosition.y *= 1.0 + vProgressVisible;
gl_Position = projectionMatrix * finalPosition;
}
The sine wave creates easy distortion the place:
- Textual content in the course of the display screen stretches most
- Textual content at prime/backside stretches much less
- Impact depth scales with
uVelocity
The speed naturally decays after scrolling stops, making a easy ease-out impact that feels natural.
Including Depth with Velocity-Pushed Traces
To enrich the textual content stretch and improve the sense of depth within the dice containing the character, I added animated traces to the fragment shader that additionally reply to scroll velocity. These traces create a parallax-like impact that reinforces the sensation of depth as you scroll.
The shader creates infinite repeating traces utilizing a modulo operation on normalized depth:
void major() {
float normalizedDepth = clamp((vPosition.z - minDepth) / (maxDepth - minDepth), 0.0, 1.0);
vec3 baseColor = combine(backColor, frontColor, normalizedDepth);
// Create repeating sample with scroll-driven offset
float adjustedDepth = normalizedDepth + lineOffset;
float repeatingPattern = mod(adjustedDepth, lineSpacing);
float normalizedPattern = repeatingPattern / lineSpacing;
// Generate line with uneven smoothing for directionality
float lineIntensity = asymmetricLine(
normalizedPattern,
0.2,
lineWidth * lineSpread * 0.2,
lineEdgeSmoothingBack * 0.2,
lineEdgeSmoothingFront * 0.2
) * 0.4;
vec3 amplifiedLineColor = lineColor * 3.0;
vec3 finalColor = combine(baseColor, amplifiedLineColor, clamp(lineIntensity, 0.0, 1.0));
...
}
The magic occurs on the JavaScript facet, the place I animate the `lineOffset` uniform based mostly on scroll place and modify the blur based mostly on velocity:
const updateLinesOnScroll = (scroll, velocity) => {
// Animate line offset with scroll
lineOffsetRef.present.worth = (scroll * scrollConfig.factorOffsetLines) % lineSpacingRef.present.worth;
// Add movement blur based mostly on velocity
lineEdgeSmoothingBackRef.present.worth = 0.2 + Math.abs(velocity) * 0.2;
};
The way it works:
- The `lineOffset` uniform strikes in sync with scroll place, making traces seem to stream via the dice
- The `lineEdgeSmoothingBack` will increase with scroll velocity, creating movement blur on quick scrolls
- The modulo operation creates infinite repeating traces with out efficiency overhead
- Uneven smoothing (completely different blur on entrance/again edges) provides the traces directionality
4. Suppose Outdoors the Sq.
At this level, I had my portal system, my character, and the infinite scroll working effectively. However I struggled to seek out one thing authentic and shocking for the contact and about pages. Merely altering the display screen portal’s place felt too boring, I needed to seek out one thing new. For a number of days, I attempted to assume “outdoors the dice.”
That’s when it hit me: the display screen is only a airplane. Let’s play with it as a airplane, not as a display screen.
Morphing the Aircraft into Textual content
This concept led me to the impact for the about web page: reworking the airplane into giant letter shapes that act as a masks.
Within the video above, you may see black traces representing the 2 SVG paths used for the morph impact: the beginning rectangle and the goal textual content masks. Right here’s how this impact is constructed:
The Idea
The method makes use of GSAP’s MorphSVG to transition between two SVG paths:
- Beginning path (
rectPath): A easy rectangle with a 1px stroke define, with intermediate factors alongside every edge for easy morphing - Goal path (
rectWithText): A stuffed rectangle with the textual content lower out as a “gap” utilizing SVG’sfill-rule: evenodd
Each paths are robotically sized to match the textual content dimensions, making certain a seamless morph.
Changing Textual content to SVG and Producing the Beginning Path
I created a customized hook utilizing the text-to-svg library to generate the textual content as an SVG path, together with an oblong border path:
const { svgElement, regenerateSvg } = useTextToSvg(
"WHO",
{
fontPath: "/fonts/Anton-Common.ttf",
addRect: true, // Generate the rectangle border path
},
titleRef
);
The hook robotically:
- Converts the textual content into an SVG path
- Matches the DOM ingredient’s computed kinds (fontSize, lineHeight) for pixel-perfect alignment
Creating the Goal Path: Rectangle with Textual content Gap
After the hook generates the essential paths, I create the goal path by combining the rectangle define with the textual content path utilizing SVG’s fill-rule: evenodd:
// Get the textual content path from the generated SVG
const textPath = svgRef.present.querySelector("#textual content");
const textD = textPath.getAttribute("d");
// Get dimensions from the textual content bounding field
const bbox = textPath.getBBox();
// Mix: outer rectangle + interior textual content (which creates a gap)
const combinedPath = [
`M ${bbox.x} ${bbox.y}`, // Move to top-left
`h ${bbox.width}`, // Horizontal line to top-right
`v ${bbox.height}`, // Vertical line to bottom-right
`h -${bbox.width}`, // Horizontal line to bottom-left
'Z', // Close rectangle path
textD // Add text path (creates the hole)
].be part of(' ');
rectWithTextRef.present = doc.createElementNS("http://www.w3.org/2000/svg", "path");
rectWithTextRef.present.setAttribute('d', combinedPath);
rectWithTextRef.present.setAttribute('fill', '#000');
rectWithTextRef.present.setAttribute('fill-rule', 'evenodd'); // Vital for creating the outlet
The fill-rule: evenodd is the important thing right here, it treats overlapping paths as holes. When the rectangle path and textual content path overlap, the textual content space turns into clear, creating that “cut-out” impact.
Why GSAP’s MorphSVG Plugin?
Morphing between a easy rectangle and complicated textual content shapes is notoriously troublesome. The paths have fully completely different level counts and constructions. GSAP’s MorphSVG plugin handles this intelligently by:
- Analyzing each paths and discovering optimum level correspondences
- Utilizing the intermediate factors to create easy transitions
- Utilizing
kind: "rotational"to create a pure, spiraling morph animation
The Efficiency Problem
As soon as the morph was working, I hit a efficiency wall. Morphing giant SVG paths with many factors triggered seen body drops, particularly on lower-end gadgets. The SVG morph was easy in idea, however rendering advanced textual content paths by consistently updating SVG DOM parts was costly. I wanted 60fps, not 30fps with stutters.
The Answer: Canvas Rendering
GSAP’s MorphSVG has a robust however lesser-known function: the render callback. As a substitute of updating the SVG DOM on each body, I might render the morphing path on to an HTML5 canvas:
gsap.to(rectPathRef.present, {
morphSVG: {
form: rectWithTextRef.present,
render: draw, // Customized canvas renderer
updateTarget: false, // Do not replace the SVG DOM
kind: "rotational"
},
length: about.to.length,
ease: about.to.ease
});
// Canvas rendering perform known as on each body
perform draw(rawPath, goal) {
// Clear canvas
ctx.save();
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, canvas.width, canvas.top);
ctx.restore();
// Draw the morphing path
ctx.fillStyle = "#000";
ctx.beginPath();
for (let j = 0; j < rawPath.size; j++) {
const phase = rawPath[j];
ctx.moveTo(phase[0], phase[1]);
// Draw bezier curves from the morphing path information
for (let i = 2; i < phase.size; i += 6) {
ctx.bezierCurveTo(
phase[i], phase[i + 1],
phase[i + 2], phase[i + 3],
phase[i + 4], phase[i + 5]
);
}
if (phase.closed) ctx.closePath();
}
ctx.fill("evenodd"); // Use evenodd fill rule on canvas too
}
The draw callback receives the interpolated path information at every body and renders it onto canvas utilizing bezier curves. This strategy:
- Bypasses costly SVG DOM manipulation
- Leverages canvas’s hardware-accelerated rendering
- Maintains 60fps even with 100+ level paths
Key Lesson: The render callback in MorphSVG is extremely highly effective for optimization. Canvas rendering gave me easy efficiency with out sacrificing the attractive morph impact.
Don’t hesitate to verify MorphSVG’s superior choices within the documentation there are a lot of helpful ideas and tips.
Timeline with Video Illustrations
The impact appeared good, however one thing was nonetheless lacking, it didn’t spotlight the web page’s content material and the textual content on the About web page was too simple to skip. It didn’t make you need to learn
I went on vacation, and after I got here again, I had a ‘little’ revelation. In my life earlier than coding, I used to be a theater director, and earlier than that, I labored in cinema. Since I began coding 5 years in the past, I’ve at all times introduced myself as a developer with a background in theater and cinema. But when I’m actually trustworthy with myself, I really love working in all three fields, and I’m satisfied many bridges could be constructed between these three arts.
So I informed myself: I’m all three. That is how the about web page must be constructed.
I created a timeline divided into three elements: Cinema, Theater, and Code. Once you hover over a step, a corresponding video seems contained in the letters, like silhouettes in a miniature shadow puppet theater.
I additionally positioned the digicam so the characters seem as silhouettes + transferring slowly contained in the “who” letters.
5. Closing the Display screen / Closing the Loop
Lastly, for the contact web page, I utilized the identical “assume outdoors the sq.” precept. I needed to shut the display screen and rework it into letters spelling “MEET ME.”
The important thing was syncing the portal masks dimension with DOM parts utilizing normalized bounds:
// Normalize DOM bounds to viewport coordinates (0-1 vary)
export perform normalizeBounds(bounds, dimensions) {
return {
x: bounds.x / dimensions.width,
y: bounds.y / dimensions.top,
width: bounds.width / dimensions.width,
top: bounds.top / dimensions.top,
};
}
// Monitor the "MEET ME" textual content dimension and sync it with the portal
const letsMeetRef = useResizeObserver(() => {
const dimensions = { width: window.innerWidth, top: window.innerHeight };
const bounds = letsMeetRef.present.getBoundingClientRect();
const normalizedBounds = normalizeBounds(bounds, dimensions);
setRedSquareSize("contact", {
x: normalizedBounds.x,
y: normalizedBounds.y,
width: normalizedBounds.width,
top: normalizedBounds.top,
});
});
By giving the portal the precise normalized dimension of my DOM letters, I might orchestrate an ideal phantasm with cautious timing. The true magic occurred with GSAP’s sequencing and easing:
const animToContact = async (from) => {
// First: zoom digicam dramatically
updateCameraFov(otherCameraRef, 150, contact.present.length * 0.5, "power3.in");
// Then: increase portal to fullscreen
await gsap.to(portalMeshRef.present.materials.uniforms.uMask.worth, {
x: 0, y: 1, z: 0, w: 1, // fullscreen
length: contact.present.length * 0.6,
ease: "power2.out",
});
// Lastly: morph into letter shapes with bouncy ease
updatePortal(redSquareSize.contact, contact.present.length * 0.4, "again.out(1.2)");
// Disguise portal masks to disclose DOM textual content beneath
gsap.set(portalMeshRef.present.materials.uniforms.uMask.worth, {
x: 0.5, y: 0.5, z: 0.5, w: 0.5, // collapsed
delay: contact.present.length * 0.4,
});
};
The again.out(1.2) easing from GSAP was essential, it creates that satisfying bounce that makes the letters really feel like they’re popping into place organically, fairly than simply showing mechanically.
Should you haven’t but, check out GSAP’s easing web page, it’s a useful software for locating the right movement curve.
Taking part in with the digicam’s FOV and place additionally helped construct an actual sense of house and depth.
And with that, the loop was full. My preliminary sticker (the one which began all of it) had lengthy been put aside. Regardless that it now not had a practical goal, I felt it deserved a spot within the portfolio. So I positioned it on the Contact web page, as a small, private wink. 😉
The humorous half is that what took me essentially the most time on this web page wasn’t the code, however discovering the appropriate movies, ones that weren’t too literal, but remained coherent and evocative. They add a way of elsewhere, a quiet invitation to flee into one other world.
Similar to earlier than, the true breakthrough got here from working with what already existed, fairly than including extra.
And above all, from utilizing visuals to inform a narrative, to disclose in movement who I’m.
Conclusion
At that second, I felt the portfolio had discovered its type.
I’m very pleased with what it turned, even with its small imperfections. The about web page might be higher designed, however this website looks like me, and I like returning to it.
I hope this case research provides you motivation to create new private types. It’s a really distinctive pleasure as soon as the shape reveals itself.
Don’t hesitate to contact me in case you have technical questions. I could come again with smaller tutorials on particular elements intimately.
And thanks, Manoela and GSAP, for giving me the chance to mirror on this lengthy journey!


