As I used to be studying internet improvement, one factor I at all times liked was lovely web page transitions. Clearly, a well-made web site is rather more than that, however a clean transition between pages will at all times elevate a web site. GSAP and Barba.js permit us to do this simply in Webflow.
I’ve additionally been eager to experiment with a selected hand-drawn 3D model for some time and thought I’d take this tutorial as a possibility to lastly strive it. So the purpose right here is to rework a Webflow web site right into a gallery-style expertise: a persistent 3D scene that by no means reloads, clean web page transitions, and animations that make navigation really feel like shifting by way of a single house reasonably than leaping between pages.
We’ll use Webflow for structure, GSAP (with SplitText) for textual content and UI animations, Three.js for the 3D scene, and Barba.js for web page transitions. The JavaScript is constructed with Vite as a single bundle that you simply add as a script supply in Webflow.
Let’s break it down in a number of steps:
- Creating the fashions (Blender + Photoshop) — Hand-drawn model textures for our 3D objects
- Mission setup — Dependencies and Vite config for Webflow
- Webflow markup — Information attributes that join our structure to the JavaScript
- Three.js Expertise — The core scene, digicam, and renderer setup
- The 3D world — Fashions, lighting, and a shadow-receiving background
- Mouse interplay — Fashions that observe the cursor
- Barba integration — Wiring up web page transitions
- GSAP transitions — Animating textual content out and in, and driving the digicam
- Button hover results — SplitText animation with lowered movement help
- Refinement & accessibility — Responsiveness, efficiency, and some issues to contemplate
Right here’s the remaining end result:
1. Creating the fashions (Blender + Photoshop)
The very first step was creating the 3D fashions. This hand-drawn model is definitely fairly simple and I like that it provides the scene character with out counting on photorealism. In Blender, I modeled the objects with tough, easy geometry, no want for clean subdivision surfaces. I unwrapped the UVs and exported the UV structure as a PNG. I opened that in Photoshop and drew straight on high of the UV traces. The bottom line is that the feel reads as hand-drawn reasonably than procedural. One workflow tip that helped: I used a .psd because the picture texture supply in Blender. After I saved modifications in Photoshop, Blender up to date mechanically. That made it simple to iterate with out re-exporting my texture each time.
2. Mission setup
I began from my Webflow JS template. It runs a neighborhood server and lets me use VS Code, Cursor, or any editor to put in writing customized code for Webflow so I can bypass the native customized code limitations. Vite bundles every little thing right into a single file. I then deploy that to Netlify and add the URL as a script supply in Webflow.
3. Webflow markup
The canvas
I added a canvas aspect with the category .webgl. Three.js will connect its renderer right here. This aspect ought to sit exterior the Barba container so it by no means will get swapped once we navigate.
Barba construction
Wrap your web page content material in a div with data-barba="wrapper" (I added it straight on the physique however you possibly can create a selected aspect in the event you favor) and data-barba="container". Barba will swap the contents of the container on every navigation. One thing like:
<physique data-barba="wrapper">
<canvas class="webgl"></canvas> <!-- Persists throughout pages -->
<div data-barba="container" data-barba-namespace="pen">
<!-- Web page content material -->
</div>
</physique>
Namespace per web page
Set data-barba-namespace on the container. Every web page will get a singular worth: pen, cup, suzanne. I take advantage of my 3D mannequin file names as namespaces. You possibly can use web page names as a substitute, however this manner it’s simpler to see within the code which mannequin is linked to which particular web page. These strings are what we use to drive the digicam (extra on that in a bit).
Animation targets
I like utilizing information attributes to question my animated components in Webflow. I added data-animation="title" to heading components, data-animation="textual content" to physique textual content blocks, and data-animation="spacer" to any horizontal dividers or spacers I need to animate. Our GSAP code queries these and animates them on enter/depart.
4. Three.js Expertise
I realized Three.js with Bruno Simon’s course and I’ve caught along with his class-based construction ever since. It retains scene, digicam, renderer, assets, and utilities in clear modules. You’ll be able to adapt this to any boilerplate you need, the necessary half is having a single Expertise occasion that survives navigation.
The Expertise class is a singleton. It holds the scene, sizes, time, assets, digicam, renderer, and world. When assets are prepared, we fade out the loader with a fast GSAP timeline:
// Expertise.js
export default class Expertise {
constructor(canvas) {
this.canvas = canvas
this.scene = new THREE.Scene()
this.assets = new Assets(sources, BASE_URL)
this.progressContainer = doc.querySelector('.loader__progress')
this.progressBar = this.progressContainer.querySelector('.loader__progress-bar')
this.assets.on('progress', (progress) => {
this.progressBar.model.rework = `scaleX(${progress})`
})
this.assets.on('prepared', () => {
const loader = doc.querySelector('.loader')
gsap.timeline()
.to(this.progressContainer, { scaleX: 0, period: 1, delay: 1, ease: 'power4.inOut' })
.to(loader, {
opacity: 0,
period: 1,
ease: 'power4.inOut',
onComplete: () => { loader.model.show = 'none' }
})
})
this.digicam = new Digicam()
this.renderer = new Renderer()
this.world = new World()
// ... resize, replace, and many others.
}
}
The Digicam is a PerspectiveCamera positioned at (0, 0, 1). We animate digicam.place.x for web page transitions.
5. The 3D world
As soon as assets are prepared, the World creates the background, fashions, and setting. The fashions are positioned alongside the X axis, the digicam will slide between them as you navigate.
// World.js
this.assets.on('prepared', () => {
this.background = new Background()
this.modelsGroup = new THREE.Group()
this.scene.add(this.modelsGroup)
const modelsConfig = [
{ name: 'pen', positionX: 0 },
{ name: 'cup', positionX: 3 },
{ name: 'suzanne', positionX: 6 }
]
this.fashions = modelsConfig.map(({ identify, positionX }) =>
new Mannequin(identify, positionX, this.modelsGroup)
)
this.setting = new Surroundings()
})
Every Mannequin clones the loaded GLB scene, locations it in a gaggle at positionX, and provides a nested mouseGroup for cursor-based motion. The mouseGroup holds the precise mesh, we’ll rotate and nudge it from the World’s replace loop.
// Mannequin.js
setModel() {
this.mannequin.traverse((youngster) => {
if (youngster.isMesh) {
youngster.castShadow = true
youngster.receiveShadow = true
}
})
this.mouseGroup.add(this.mannequin)
this.guardian.add(this.group)
}
The Background is a big aircraft with ShadowMaterial set to opacity: 0.3, positioned barely behind the fashions at z: -0.25. It receives shadows from the fashions and grounds the scene. ShadowMaterial is price understanding about as a result of it renders as totally clear besides the place shadows fall. Which means the aircraft itself is invisible, however the shadows it catches mix straight onto no matter is behind the canvas. No opaque background, no colour matching wanted. It’s a easy option to make the 3D scene really feel prefer it lives on the web page reasonably than inside a field.
To push this additional, I added a paper texture as a picture positioned completely behind the canvas, then set the canvas mixing mode to multiply. This manner the WebGL output blends with the paper grain beneath, and the fashions find yourself with this good paper-craft look. Small tweaks like this generally make an enormous distinction. In our case, it helps the 3D scene really feel handmade reasonably than digital.
The Surroundings provides ambient and directional lights.
6. Mouse interplay
I wished the fashions to react subtly to the cursor, nothing sophisticated. A single mousemove listener shops the offset from the middle of the display screen. Every body we lerp towards goal rotation and place values derived from that offset.
// World.js
setMouseMove() {
doc.addEventListener('mousemove', (occasion) => {
const windowX = window.innerWidth / 2
const windowY = window.innerHeight / 2
this.mouseX = occasion.clientX - windowX
this.mouseY = occasion.clientY - windowY
})
}
replace() {
if (!this.fashions) return
this.targetRotationX = this.mouseY * 0.0005
this.targetRotationY = this.mouseX * 0.0005
this.targetPositionX = this.mouseX * 0.000015
this.targetPositionY = -this.mouseY * 0.000015
this.currentRotationX += this.easeFactor * (this.targetRotationX - this.currentRotationX)
this.currentRotationY += this.easeFactor * (this.targetRotationY - this.currentRotationY)
// ... identical for place
this.fashions.forEach((mannequin) => {
mannequin.mouseGroup.rotation.x = this.currentRotationX
mannequin.mouseGroup.rotation.y = this.currentRotationY
mannequin.mouseGroup.place.x = this.currentPositionX
mannequin.mouseGroup.place.y = this.currentPositionY
})
}
The easeFactor of 0.08 controls how shortly the fashions catch as much as the cursor. It’s price experimenting with: the next worth makes the response really feel snappy however can look jittery, whereas a decrease worth provides a smoother, floatier really feel. I landed on 0.08 as a center floor that feels responsive with out being twitchy.
7. Barba integration
Barba drives the navigation. We outline a single transition with as soon as, depart, and enter hooks. The Expertise is created as soon as and reused for the entire session.
// primary.js
barba.init({
preventRunning: true,
stop: ({ href, occasion }) => {
// Stop navigation if hyperlink is the present web page
if (href === window.location.href) {
occasion.preventDefault()
occasion.stopPropagation()
return true
}
return false
},
transitions: [{
name: 'default-transition',
once({ next }) {
setActiveNavButton(next.url.href)
experience = new Experience(document.querySelector('.webgl'))
animateCameraToNamespace(next.namespace, experience)
},
leave(data) {
setActiveNavButton(data.next.url.href)
return transitionOut(data)
},
enter(data) {
animateCameraToNamespace(data.next.namespace, experience)
return transitionIn(data)
}
}]
})
as soon as runs on the primary web page load: we create the Expertise and animate the digicam to the present namespace. depart runs earlier than the DOM swap, we return the transitionOut promise so Barba waits for our animation to complete. enter runs after the brand new content material is in place, we animate the digicam to the brand new namespace and run transitionIn. The digicam and the content material animate in parallel, which is what makes it really feel cohesive.
8. GSAP web page transitions
The transition logic lives in animations.js. We use SplitText to interrupt textual content into traces so we will stagger the animation, which feels rather more natural than animating the entire block without delay. For transitionOut, we animate titles and textual content traces upward (yPercent: -100), fade them out, and scale spacers to zero. The spacers use transformOrigin: 'proper middle' on depart in order that they shrink towards the appropriate; on enter we use 'left middle' in order that they develop from the left. Small element, however it makes the route really feel intentional.
// animations.js
export operate transitionOut(information) {
return new Promise((resolve) => {
const container = information?.present?.container
const titleElements = container?.querySelectorAll('[data-animation="title"]') ?? []
const textElement = container?.querySelector('[data-animation="text"]') ?? null
const spacerElements = container?.querySelectorAll('[data-animation="spacer"]') ?? []
let textLines = null
if (textElement) {
const break up = new SplitText(textElement, { sort: 'traces', linesClass: 'text-line' })
textLines = break up.traces ?? null
}
gsap.timeline({ onComplete: () => resolve() })
.to(titleElements, {
yPercent: -100,
opacity: 0,
period: 0.8,
ease: 'power4.in',
stagger: 0.2,
})
.to(
textLines && textLines.size ? textLines : textElement,
{
opacity: 0,
yPercent: -100,
period: 0.8,
ease: 'power4.in',
stagger: 0.1,
},
0
)
.to(spacerElements, {
scaleX: 0,
period: 0.8,
ease: 'power4.in',
transformOrigin: 'proper middle'
}, 0)
})
}
For transitionIn, we set preliminary states (components under the fold with yPercent: 100, spacers at scaleX: 0 with transformOrigin: 'left middle') then animate to their pure state. The easing switches to 'expo.out' for a handy guide a rough entrance, and every property group will get a slight delay, 0.2s for titles, 0.35s for textual content traces and spacers so the content material cascades in reasonably than showing all of sudden.
The digicam animation is an easy GSAP tween. We map namespaces to X positions that match the mannequin structure.
// animations.js
export const cameraPositionsByNamespace = {
pen: 0,
cup: 3,
suzanne: 6
}
export operate animateCameraToNamespace(namespace, expertise = null) {
if (!expertise?.digicam?.occasion) return
const targetX = cameraPositionsByNamespace[namespace] ?? 0
gsap.to(expertise.digicam.occasion.place, {
x: targetX,
period: 2,
ease: 'expo.inOut'
})
}
9. Button hover results
For the nav buttons, we use SplitText with sort: 'chars' and create a “backside” layer that slides up on hover. The impact: the highest characters transfer up and away whereas the underside duplicates slide into place. We additionally morph the borderRadius from 0.25rem to 0.5rem to melt the form on hover. It’s a pleasant contact.
I wrapped every little thing in gsap.matchMedia so the impact solely runs when the consumer hasn’t requested lowered movement:
// buttons.js
const mm = gsap.matchMedia()
mm.add('(min-width: 992px) and (prefers-reduced-motion: no-preference)', () => {
const buttons = doc.querySelectorAll('.button')
buttons.forEach((button) => {
const textWrapper = button.querySelector('.button__text-wrapper')
const textual content = textWrapper.querySelector('.button__text')
const break up = new SplitText(textual content, { sort: 'chars' })
const chars = break up.chars
const bottomText = textual content.cloneNode(true)
bottomText.classList.add('button__text--bottom')
bottomText.model.place = 'absolute'
bottomText.model.high = '0'
bottomText.model.left = '0'
bottomText.model.width = '100%'
textWrapper.appendChild(bottomText)
const splitBottom = new SplitText(bottomText, { sort: 'chars' })
const bottomChars = splitBottom.chars
gsap.set(bottomChars, { yPercent: 100 })
button.addEventListener('mouseenter', () => {
gsap.to(button, { borderRadius: '0.5rem', period: 0.8, ease: 'power4.out' })
gsap.to(chars, { yPercent: -100, period: 0.8, stagger: 0.02, ease: 'power4.out' })
gsap.to(bottomChars, { yPercent: 0, period: 0.8, stagger: 0.02, ease: 'power4.out' })
})
button.addEventListener('mouseleave', () => {
gsap.to(button, { borderRadius: '0.25rem', period: 0.8, ease: 'power4.out' })
gsap.to(chars, { yPercent: 0, period: 0.8, stagger: 0.02, ease: 'power4.out' })
gsap.to(bottomChars, { yPercent: 100, period: 0.8, stagger: 0.02, ease: 'power4.out' })
})
})
})
When prefers-reduced-motion: scale back is about, the callback by no means runs and the buttons behave usually. The min-width: 992px situation additionally means we skip the impact on smaller screens the place hover interactions don’t apply.
10. Refinement
Responsiveness
The canvas wants to answer structure modifications. Somewhat than listening to window.resize, we use a Sizes utility with a ResizeObserver hooked up on to the canvas aspect. When it resizes, we replace the digicam side ratio and the renderer dimension. The pixel ratio can be recalculated on resize, however capped at 2, something greater tanks efficiency on retina screens with no seen distinction.
Single Expertise
The Three.js scene is created as soon as on preliminary load and persists for your entire session. Barba solely swaps the HTML contained in the container; the canvas, the scene, and all loaded assets keep untouched. This implies no re-initialization, no flickering, and no repeated community requests for fashions.
Mannequin loading
Every GLB is loaded as soon as by way of a Assets utility that makes use of Three.js’s GLTFLoader with DRACOLoader for compression. After we want a mannequin within the scene, we clone its scene reasonably than loading it once more. DRACO compression retains the file sizes small, price enabling in the event you’re delivery GLBs to manufacturing.
Remaining end result
A Webflow web site with a persistent Three.js scene, Barba.js web page transitions, and GSAP-driven animations. The 3D world by no means reloads, and the digicam slides between fashions as you navigate. Mouse-reactive fashions add a little bit of life, and the loader plus reduced-motion help preserve issues polished.
A fast observe on usability & accessibility
Movement sensitivity. Respect the consumer’s prefers-reduced-motion setting. We’ve carried out it for the button animations; you could possibly lengthen the identical strategy to web page transitions, both skip the GSAP animations for an on the spot swap, or use very quick durations. A matchMedia test at the beginning of transitionOut and transitionIn can department accordingly.
Semantic construction. Use correct headings and landmarks in Webflow. The 3D canvas is ornamental, the precise content material needs to be navigable and readable by assistive tech.
What’s subsequent
You possibly can lengthen the scene to really feel like actual areas: room geometry, props, digicam paths on web page change. Or preserve the digicam fastened and animate/swap fashions as a substitute. Or tie digicam place to scroll for a parallax-like impact. Swap the GLB fashions in your personal, regulate cameraPositionsByNamespace to match, tweak easings and stagger values.
Thanks for following alongside! I’m excited to see what you’ll create. When you have any questions, be at liberty to drop me a line.


