After Stas Bondar’s portfolio was named Website of the Week and Website of the Month by the GSAP crew, we knew we needed to take a better look. On this unique deep dive, Stas walks us via the code, animation logic, and inventive course of that introduced his beautiful portfolio to life.
Each completed portfolio has a narrative behind it, and mine isn’t any exception. What guests see as we speak at stabondar.com is the results of greater than a yr of planning, experimentation, iteration, and refinement. It was fairly a journey—juggling important tasks right here and there, squeezing in time for my web site late at evening or on weekends. Taking part in round with completely different animation situations and transitions, solely to redo every part when it didn’t end up the best way I anticipated… after which redoing it once more. Yeah, it was an extended experience.
I’m thrilled to share that two months in the past, after dedicating numerous hours to improvement, I lastly launched my new portfolio web site! My imaginative and prescient was daring: to create a vibrant showcase that actually displays the thrilling prospects of prioritizing animation and interplay design in internet improvement.
This backstage tour will dive into how GSAP helped sort out key challenges, strolling via the technical implementation of animations and sharing code examples you’ll be able to adapt on your personal tasks.
Technical Overview
For my portfolio web site, I wanted a tech stack that supplied each flexibility for inventive animations and strong efficiency. Right here’s a breakdown of the important thing applied sciences and the way they work collectively:
Astro Construct
Not way back, each mission I labored on was in-built Webflow. Whereas it’s a strong platform, I noticed I wasn’t utilizing Webflow’s built-in interactions—I all the time most well-liked to handcraft each animation, interplay, and web page transition myself. Sooner or later, it hit me: I used to be basically utilizing Webflow as a static HTML generator, whereas all of the dynamic components have been customized code.
That realization led me to discover alternate options, and Astro turned out to be the right answer. I wished a framework that allowed me to rapidly construction HTML and dive straight into animations with out pointless overhead. Astro gave me precisely that—a streamlined improvement expertise with:
- Quick web page masses via partial hydration
- A easy part structure that saved my code organized
- Minimal JavaScript by default, permitting me so as to add solely what I wanted
- Wonderful dealing with of static property—essential for a portfolio website
- The flexibleness to make use of trendy JavaScript options whereas nonetheless delivering optimized output
Animations: GSAP
Once I talked about customized animations in Webflow, I used to be actually speaking about GSAP. The GreenSock Animation Platform has been the spine of my tasks for years, and I hold discovering new options that make me adore it much more.
I nonetheless vividly bear in mind engaged on Dmitry Kutsenko’s portfolio 4 years in the past. Again then, I wasn’t significantly comfy with JavaScript and relied closely on Webflow’s built-in interactions. For title animations, I needed to manually break up every character into particular person spans, then painstakingly animate them one after the other in Webflow’s interface. I repeated this tedious course of for navigation gadgets and different components all through the positioning.
const title = doc.querySelector('.title')
const break up = new SplitText(title, { kind: 'strains, chars' })
gsap.set(break up.strains, { overflow: 'hidden' })
gsap.fromTo(break up.chars,
{ yPercent: 100, opacity: 0 },
{ yPercent: 0, opacity: 1, stagger: 0.02, ease: 'power2.out' }
)
Only a few strains of code can exchange days of guide work, making the system extra versatile, simpler to keep up, and sooner. What as soon as appeared like magic is now a key device in my improvement toolkit.
3D and Visible Results: Three.js
Lately, I found Three.js for myself. It began with Bruno Simon’s Three.js Journey. This unbelievable studying platform utterly remodeled my understanding of what’s doable in web-based 3D and expanded my inventive horizons. Writing customized shaders from scratch remains to be a major problem, however I’ve loved the training course of immensely!
For my portfolio, Three.js offered the right toolset for creating immersive 3D results that complement the GSAP animations. The WebGL-powered visuals add depth and interactivity that wouldn’t be doable with commonplace DOM animations.
Seamless Web page Transitions: Barba.js
I wished my portfolio to really feel like a single, fluid expertise whereas nonetheless having common web site URLs for every part. Barba.js helped me create clean transitions between pages as a substitute of the standard abrupt web page reloads.
Structure Overview
The code structure is designed round elements that may be animated independently whereas nonetheless coordinating with one another:
src/
├── actions/ # Server-side kind actions
├── elements/ # UI elements and structure components
├── content material/ # CMS-like content material storage
├── js/ # Shopper-side JavaScript
│ ├── App.js # Primary software entry level
│ ├── elements/ # Reusable JS elements
│ │ ├── 3D/ # Three.js elements
│ │ ├── EventEmitter/ # Customized occasion system
│ │ ├── transition/ # Web page transition elements
│ │ └── ...
│ ├── pages/ # Web page-specific logic
│ │ ├── house/ # Dwelling web page animations
│ │ │ ├── world/ # Three.js scene for house web page
│ │ │ ├── Awards.js # Awards part animations
│ │ │ ├── Dice.js # 3D dice animations
│ │ │ └── ...
│ │ ├── instances/ # Case research animations
│ │ ├── contact/ # Contact web page animations
│ │ └── error/ # 404 web page animations
│ ├── transitions/ # Web page transition definitions
│ └── utils/ # Helper capabilities
├── layouts/ # Astro structure templates
├── pages/ # Astro web page routes
├── static/ # Static property
└── kinds/ # SCSS kinds
Dwelling Web page
I assume that is the place I spent most of my time 😅
#1: Hero Part
The very first thing was the reel video. A plain rectangle felt monotonous, and I wasn’t actually into that look. I additionally wished so as to add some visible results to make it extra playful however not too overwhelming. So, I used the dithering impact (thanks, Maxime, for the fabulous tutorial on it) throughout virtually the complete web site for pictures and movies.
// The Bayer matrix defines the dithering sample
const float bayerMatrix8x8[64] = float[64](
0.0/64.0, 48.0/64.0, 12.0/64.0, 60.0/64.0, /* and so forth... */
);
// Perform to use ordered dithering to a colour
vec3 orderedDither(vec2 uv, vec3 colour)
{
// Discover the corresponding threshold within the Bayer matrix
int x = int(uv.x * uRes.x) % 8;
int y = int(uv.y * uRes.y) % 8;
float threshold = bayerMatrix8x8[y * 8 + x] - 0.88;
// Add the brink and quantize the colour to create the dithering impact
colour.rgb += threshold;
colour.r = flooring(colour.r * (uColorNum - 1.0) + 0.5) / (uColorNum - 1.0);
colour.g = flooring(colour.g * (uColorNum - 1.0) + 0.5) / (uColorNum - 1.0);
colour.b = flooring(colour.b * (uColorNum - 1.0) + 0.5) / (uColorNum - 1.0);
return colour;
}
// Primary shader operate
void important()
{
vec2 uv = vUv;
// Apply pixelation impact
float pixelSize = combine(uPixelSize, 1.0, uProgress);
vec2 normalPixelSize = pixelSize / uRes;
vec2 uvPixel = normalPixelSize * flooring(uv / normalPixelSize);
// Pattern the video texture
vec4 texture = texture2D(uTexture, uvPixel);
// Calculate luminance for grayscale dithering
float lum = dot(vec3(0.2126, 0.7152, 0.0722), texture.rgb);
// Apply dithering to the luminance
vec3 dither = orderedDither(uvPixel, vec3(lum));
// Combine between dithered and unique based mostly on hover progress
vec3 colour = combine(dither, texture.rgb, uProgress);
// Last output with alpha dealing with
gl_FragColor = vec4(colour, alpha);
}
Additionally, some GSAP magic to animate completely different states.
this.merchandise.addEventListener('mouseenter', () =>
{
gsap.to(this.materials.uniforms.uProgress,
{
worth: 1,
period: 1,
ease: 'power3.inOut'
})
})
// When the person strikes away
this.merchandise.addEventListener('mouseleave', () =>
{
gsap.to(this.materials.uniforms.uProgress,
{
worth: this.videoEl.muted ? 0 : 1,
period: 1,
ease: 'power3.inOut'
})
})
#2: Falling Textual content – Breaking Conventions with Physics
That is my favourite a part of the web site—a nontraditional method to the ever present “about me” part. As a substitute of a normal paragraph, I offered my story via a physics-driven textual content animation that breaks aside as you have interaction with it.
The textual content combines my skilled background with private insights—from my journey as an athlete to my love for gaming and journey. I intentionally highlighted “Inventive Developer” in a distinct colour to emphasise my skilled id, creating visible anchors all through the prolonged textual content.
What makes this part technically fascinating is its layered implementation:
First, I take advantage of GSAP’s SplitText plugin to interrupt the textual content into particular person components:
// Break up textual content into phrases, then particular phrases into characters
this.break up = new SplitText(this.textual content,
{
kind: 'phrases',
wordsClass: 'text-words',
place: 'absolute'
})
this.spans = this.wrapper.querySelectorAll('span')
this.spanSplit = new SplitText(this.spans,
{
kind: 'chars',
charsClass: 'text-char',
place: 'relative'
})
Subsequent, I create a physics world utilizing Matter.js
and add every character as a physics physique.
// Create a physics physique for every character
this.splitToBodies.forEach((char, index) =>
{
const rect = char.getBoundingClientRect()
const field = Matter.Our bodies.rectangle(
rect.left + rect.width / 2,
rect.prime + rect.top / 2,
rect.width,
rect.top,
{
isStatic: false,
restitution: Math.random() * 1.2 + 0.1 // Random bounciness
}
);
Matter.World.add(this.world, field)
this.our bodies.push({ field, char, prime: rect.prime, left: rect.left })
})
When triggered by scrolling, the enterFalling()
methodology prompts the physics simulation with random forces.
enterFalling()
{
// Allow physics rendering
this.allowRender = true
// Add a category for CSS transitions
this.fallingWrapper.classList.add('falling')
// Apply slight random forces to every character
this.our bodies.forEach(physique =>
{
const randomForceY = (Math.random() - 0.5) * 0.03
Matter.Physique.applyForce(
physique.field,
{ x: physique.field.place.x, y: physique.field.place.y },
{ x: 0, y: randomForceY }
)
})
}
Lastly, I take advantage of ScrollTrigger to activate the physics simulation on the right scroll place.
this.fallingScroll = ScrollTrigger.create(
{
set off: this.wrapper,
begin: `prime top-=${this.isDesktop ? 375 : 300}%`,
onEnter: () => this.enterFalling(),
onLeaveBack: () => this.backFalling()
})
When the physics is activated, every character responds independently to gravity and collisions, creating an natural falling impact that transforms the formal textual content right into a playful, interactive ingredient. As customers proceed to scroll, they’ll even “push” the textual content away with scroll momentum—a element that rewards experimentation.
#3: The Interactive Dice
Following the physics-driven textual content animation, guests encounter certainly one of my portfolio’s most technically advanced components: the 3D dice that showcases my awards and achievements. This part transforms what might have been a easy checklist into an interactive 3D expertise that invitations exploration.
The dice is constructed utilizing a mixture of pure CSS 3D transforms and GSAP for animation management. The fundamental construction consists of a container with six faces, every positioned in 3D area:
// Establishing the 3D setting
this.dice = this.important.querySelector('.dice')
this.components = this.important.querySelectorAll('.cube_part')
this.tiles = this.important.querySelectorAll('.cube_tile')
// Making a timeline for the dice transformation
this.tl = gsap.timeline({paused: true})
// Animating the dice components with exact timing
this.tl.to(this.components, {'--sideRotate': 1, stagger: 0.2}, 2)
The true magic occurs when ScrollTrigger
comes into play, controlling the dice’s rotation based mostly on the scroll place.
this.scrollRotate = ScrollTrigger.create(
{
set off: this.wrapper,
begin: 'prime prime',
finish: 'backside backside',
scrub: true,
onUpdate: (self) =>
{
const progress = self.progress;
const x = progress * 10;
const y = progress * 10;
this.values.scrollX = x;
this.values.scrollY = y;
}
})
One in all my favourite GSAP utilities that deserves extra recognition is gsap.quickTo(). This operate has been a game-changer for dealing with steady animations, particularly these pushed by mouse motion or scroll place, just like the dice’s rotation.
As a substitute of making and destroying a whole lot of tweens for every body replace (which might trigger efficiency points), I take advantage of quickTo
to create reusable animation capabilities:
// Create re-usable animation capabilities throughout initialization
this.cubeRotateX = gsap.quickTo(this.dice, '--rotateY', {period: 0.4})
this.cubeRotateY = gsap.quickTo(this.dice, '--rotateX', {period: 0.4})
// Within the replace operate, merely name these capabilities with the brand new values
replace()
{
this.cubeRotateX(this.axis.y + this.values.mouseX + this.values.scrollX)
this.cubeRotateY(this.axis.x - this.values.mouseY + this.values.scrollY)
}
The distinction is profound. As a substitute of making and destroying a whole lot of tweens per second, I’m merely updating the goal values of current tweens. The animation stays clean even throughout speedy mouse actions or advanced scroll interactions.
This method is used all through my portfolio for performance-critical animations:
// For the awards cursor
this.quickX = gsap.quickTo(this.part, '--x', {period: 0.2, ease: 'power2.out'})
this.quickY = gsap.quickTo(this.part, '--y', {period: 0.2, ease: 'power2.out'})
Past scroll management, the dice additionally responds to mouse motion for a further layer of interactivity:
window.addEventListener('mousemove', (e) =>
{
if(!this.isInView) return
const x = (e.clientX / window.innerWidth - 0.5) * 4
const y = (e.clientY / window.innerHeight - 0.5) * 4
this.values.mouseX = x
this.values.mouseY = y
})
#4: Initiatives Grid with WebGL Enhancements
The ultimate part of the homepage includes a grid of my tasks, every enhanced with WebGL results that reply to person interplay. At first look, what seems to be a normal portfolio grid is definitely a complicated mix of Three.js and GSAP animations working collectively to create an immersive expertise.
Every mission thumbnail includes a customized dithering shader much like the one used within the hero video however with further interactive behaviors:
this.imgsStore.forEach(({img, materials}) =>
{
const merchandise = img.parentElement
merchandise.addEventListener('mouseenter', () =>
{
gsap.to(materials.uniforms.uHover, {worth: 1, period: 0.4})
})
merchandise.addEventListener('mouseleave', () =>
{
gsap.to(materials.uniforms.uHover, {worth: 0, period: 0.4})
})
})
The shader itself applies ordered dithering utilizing a Bayer matrix to create a particular visible model:
vec3 orderedDither(vec2 uv, vec3 colour)
{
float threshold = 0.0
int x = int(uv.x * uRes.x) % 8
int y = int(uv.y * uRes.y) % 8
threshold = bayerMatrix8x8[y * 8 + x] - 0.88
colour.rgb += threshold
colour.r = flooring(colour.r * (uColorNum - 1.0) + 0.5) / (uColorNum - 1.0)
colour.g = flooring(colour.g * (uColorNum - 1.0) + 0.5) / (uColorNum - 1.0)
colour.b = flooring(colour.b * (uColorNum - 1.0) + 0.5) / (uColorNum - 1.0)
return colour
}
Enhancing the sense of depth, every mission picture strikes at a barely completely different price because the person scrolls, making a parallax impact:
this.scrollTl = gsap.timeline({defaults: {ease: 'none'}})
this.imgsStore.forEach(({img}, index) =>
{
const random = gsap.utils.random(0.9, 1.1, 0.05)
this.scrollTl.fromTo(img.parentElement,
{ '--scrollY': 0 },
{ '--scrollY': -this.sizes.top / 2 * random }, 0)
})
ScrollTrigger.create(
{
set off: this.part,
begin: 'prime prime',
finish: 'backside bottom-=100%',
scrub: true,
animation: this.scrollTl,
})
I take advantage of one other GSAP utility methodology to randomize the motion speeds (random = gsap.utils.random(0.9, 1.1, 0.05)
) making certain that every mission strikes at a barely completely different price, making a extra pure and dynamic scrolling expertise.
Past scroll results, the tasks additionally reply to mouse motion utilizing the identical quickTo
method employed within the dice part:
parallax()
{
this.mouse = {x: 0, y: 0}
window.addEventListener('mousemove', (e) =>
{
this.mouse.x = e.clientX - window.innerWidth / 2
this.mouse.y = e.clientY - window.innerHeight / 2
this.gadgets.forEach((merchandise, index) =>
{
const quickX = this.quicksX[index]
const quickY = this.quicksY[index]
const x = this.mouse.x * 0.6 * -1 * this.ramdoms[index]
const y = this.mouse.y * 0.6 * -1 * this.ramdoms[index]
quickX(x)
quickY(y)
})
})
}
Every mission thumbnail strikes in response to the mouse place however at barely completely different charges, based mostly on the identical random values used for scroll parallax. This creates a cohesive sense of depth that seamlessly blends scrolling and mouse interactions.
Essentially the most spectacular facet of this part is how the tasks transition into their respective case research. When a person clicks a mission, Barba.js and GSAP’s Flip plugin work collectively to create a clean visible transition:
this.state = Flip.getState(this.currentImg)
this.nextContainerImageParent.appendChild(this.currentImg)
this.flip = Flip.from(this.state,
{
period: 1.3,
ease: 'power3.inOut',
onUpdate: () => this.replace(this.flip.progress())
})
This creates the phantasm that the thumbnail is morphing into the hero picture of the case examine web page, offering visible continuity between pages.
Circumstances Web page
After exploring the revolutionary components of the homepage, let’s dive into the Circumstances web page—my absolute favourite part of the portfolio. This web page represents numerous hours of experimentation and refinement. I nonetheless get misplaced within the interactions, shifting the cursor round and scrolling columns independently simply to see the visible results in movement.
The Circumstances web page showcases tasks in a multi-column structure that adapts based mostly on viewport dimension and responds to the touch and drag via GSAP’s Draggable plugin. One in all its most putting points is that every column operates independently of the others by way of scroll conduct.
The variety of seen columns is dynamically decided based mostly on display screen width:
getColLength()
{
let colLength = 0
if(this.sizes.width > this.breakpoint.pill)
{
colLength = 5 // Desktop view
}
else if(this.sizes.width > this.breakpoint.cell)
{
colLength = 3 // Pill view
}
else
{
colLength = 1 // Cell view
}
return colLength
}
Every column maintains its personal scrolling context, leading to a visually participating grid that invitations exploration.
init(splitItem, randomScrollPosition)
{
let begin = window.innerWidth < this.breakpoint.cell ? 100 : randomScrollPosition
let scrollSpeed = 0
let oldScrollY = begin
let scrollY = begin
// Initialize every column with a random scroll place
const index = Array.from(splitItem.parentElement.youngsters).indexOf(splitItem)
const checklist = splitItem
const merchandise = checklist.querySelectorAll('.project_item')
// Arrange mouse wheel interplay for every column
checklist.addEventListener('wheel', (e) => scrollY = this.handleMouseWheel(e, scrollY))
// Add contact and drag capabilities via GSAP's Draggable
Draggable.create(splitItem,
{
kind: 'y',
inertia: true,
onDrag: operate()
{
gsap.set(splitItem, {y: 0, zIndex: 1})
scrollY += this.deltaY * 0.8
}
})
}
One of the vital distinctive points of the Circumstances web page is how mission thumbnails react to scrolling with practical, physics-based distortions.
The scroll velocity is calculated by monitoring the distinction between the present and former scroll positions:
replace()
{
y = this.lerp(y, scrollY, 0.1)
scrollSpeed = y - oldScrollY
if(Math.flooring(oldScrollY) != Math.flooring(y))
{
this.updateScroll(y, merchandise, numItems, itemHeight, wrapHeight, numItemHeight, numWrapHeight, index)
this.projectScrollSpeed[index] = Math.abs(scrollSpeed)
}
oldScrollY = y
}
The scroll velocity is then handed to the fragment shader to generate dynamic distortion results.
// Fragment shader excerpt exhibiting scroll-velocity based mostly distortion
float pace = clamp(uScrollSpeed, 0.0, 60.0)
float normalizedSpeed = clamp(uScrollSpeed / 20.0, 0.0, 1.0)
float invertStrength = pow(normalizedSpeed, 1.2)
float space = smoothstep(0.3, 0., vUv.y)
space = pow(space, 2.0)
float area2 = smoothstep(0.7, 1.0, vUv.y)
area2 = pow(area2, 2.0)
space += area2
uv.x -= (vUv.x - uLeft) * 0.1 * space * pace * 0.1
vec4 displacement = texture2D(uDisplacement, uv)
uv -= displacement.rg * 0.005
Case Research Web page
Transferring past the dynamic structure of the Circumstances web page, every particular person case examine presents a novel alternative to spotlight not solely the ultimate outcomes of shopper tasks but additionally the precise technical approaches and animations utilized in these tasks. As a substitute of making use of a uniform template to all case research, I designed every element web page to mirror the distinctive strategies and visible language of the unique mission.
Distinctive Animation Techniques for Every Undertaking
Each case examine web page options animations and interactions that mirror these I developed for the shopper mission. This method serves two functions: it demonstrates the strategies in a sensible context and permits guests to expertise the mission’s interactive components firsthand.
// Instance from Runway case examine web page - atmospheric cloud simulation
init()
{
this.sky = await import('./meshs/sky')
this.sky = new this.sky.default(this.app, this.important, this.gl, this.assets)
this.skyOutput = await import('./meshs/skyOutput')
this.skyOutput = new this.skyOutput.default(this.app, this.important, this.gl)
}
For instance, the Runway case examine showcases a cloud simulation method developed particularly for that mission. In distinction, the Bulletproof case options dynamic SVG morphing transitions which might be central to its design.
Present Part Navigation
One of the vital technically intriguing elements throughout all case research is the present part highlighter, which visually signifies the person’s place throughout the case examine content material.
The part tracker works by monitoring scroll positions and updating a navigation indicator accordingly:
init()
{
this.scrolls = []
this.sections.forEach((part, index) =>
{
const currentText = part.getAttribute('current-section')
const prevSection = this.sections[index - 1]
const prevText = prevSection ? prevSection.getAttribute('current-section') : ''
const nextSection = this.sections[index + 1]
const nextText = nextSection ? nextSection.getAttribute('current-section') : ''
const set off = ScrollTrigger.create(
{
set off: part,
begin: 'prime 50%',
finish: 'backside 50%',
onEnter: () =>
{
if(!prevSection && currentText !== this.currentText)
{
this.changeTitle(currentText)
}
else if(prevText !== currentText && currentText !== this.currentText)
{
this.changeTitle(currentText)
}
},
onEnterBack: () =>
{
if(!nextSection && currentText !== this.currentText)
{
this.changeTitle(currentText)
}
else if(nextText !== currentText && currentText !== this.currentText)
{
this.changeTitle(currentText)
}
},
})
this.scrolls.push(set off)
})
}
This method creates ScrollTrigger
situations for every content material part, detecting when a piece enters or leaves the viewport. When this occurs, the system updates the navigation title to mirror the present part.
What makes this method significantly participating is the textual content transition animation that happens when switching between sections:
changeTitle(textual content)
{
if(this.break up) this.break up.revert()
this.navTitle.innerHTML = textual content
this.break up = new SplitText(this.navTitle, {kind: 'chars, strains', charsClass: 'char'})
this.animation = gsap.timeline()
this.break up.strains.forEach((line, index) =>
{
const chars = line.querySelectorAll('.char')
this.animation.from(chars, this.charsAnimation, index * 0.1)
.from(chars, this.charsScrumble, index * 0.1)
})
this.currentText = textual content
}
Quite than merely updating the textual content, the system creates a visually participating transition utilizing GSAP’s SplitText plugin. The textual content characters scramble and fade in, making a dynamic typographic impact that attracts consideration to the part change.
Thank You for Studying!
Thanks for taking the time to discover this mission with me. Pleased coding, and will your individual inventive endeavors be each technically fascinating and visually fascinating!