As I used to be studying net improvement, one factor I all the time beloved was lovely web page transitions. Clearly, a well-made web site is far more than that, however a easy transition between pages will all the time elevate a web site. GSAP and Barba.js enable us to do this simply in Webflow.
I’ve additionally been eager to experiment with a particular hand-drawn 3D type for some time and thought I’d take this tutorial as a possibility to lastly strive it. So the objective right here is to remodel a Webflow web site right into a gallery-style expertise: a persistent 3D scene that by no means reloads, easy web page transitions, and animations that make navigation really feel like shifting by a single house moderately 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 just a few steps:
- Creating the fashions (Blender + Photoshop) — Hand-drawn type textures for our 3D objects
- Challenge setup — Dependencies and Vite config for Webflow
- Webflow markup — Knowledge attributes that join our structure to the JavaScript
- Three.js Expertise — The core scene, digital camera, 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 digital camera
- Button hover results — SplitText animation with decreased movement help
- Refinement & accessibility — Responsiveness, efficiency, and some issues to contemplate
Right here’s the closing consequence:
1. Creating the fashions (Blender + Photoshop)
The very first step was creating the 3D fashions. This hand-drawn type is definitely fairly easy and I like that it provides the scene persona with out counting on photorealism. In Blender, I modeled the objects with tough, easy geometry, no want for easy subdivision surfaces. I unwrapped the UVs and exported the UV structure as a PNG. I opened that in Photoshop and drew immediately on prime of the UV strains. The secret is that the feel reads as hand-drawn moderately than procedural. One workflow tip that helped: I used a .psd because the picture texture supply in Blender. Once I saved adjustments in Photoshop, Blender up to date routinely. That made it simple to iterate with out re-exporting my texture each time.
2. Challenge setup
I began from my Webflow JS template. It runs an area server and lets me use VS Code, Cursor, or any editor to jot down 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 immediately on the physique however you possibly can create a particular aspect in case you favor) and data-barba="container". Barba will swap the contents of the container on every navigation. One thing like:
Namespace per web page
Set data-barba-namespace on the container. Every web page will get a singular worth: pen, cup, suzanne. I exploit my 3D mannequin file names as namespaces. You would use web page names as an alternative, 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 digital camera (extra on that in a bit).
Animation targets
I like utilizing knowledge 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/go away.
4. Three.js Expertise
I realized Three.js with Bruno Simon’s course and I’ve caught together with his class-based construction ever since. It retains scene, digital camera, renderer, assets, and utilities in clear modules. You possibly can 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, digital camera, 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.type.rework = `scaleX(${progress})`
})
this.assets.on('prepared', () => {
const loader = doc.querySelector('.loader')
gsap.timeline()
.to(this.progressContainer, { scaleX: 0, length: 1, delay: 1, ease: 'power4.inOut' })
.to(loader, {
opacity: 0,
length: 1,
ease: 'power4.inOut',
onComplete: () => { loader.type.show = 'none' }
})
})
this.digital camera = new Digital camera()
this.renderer = new Renderer()
this.world = new World()
// ... resize, replace, and so forth.
}
}
The Digital camera is a PerspectiveCamera positioned at (0, 0, 1). We animate digital camera.place.x for web page transitions.
5. The 3D world
As soon as assets are prepared, the World creates the background, fashions, and surroundings. The fashions are positioned alongside the X axis, the digital camera 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.surroundings = new Setting()
})
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((little one) => {
if (little one.isMesh) {
little one.castShadow = true
little one.receiveShadow = true
}
})
this.mouseGroup.add(this.mannequin)
this.father or mother.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 realizing 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 immediately onto no matter is behind the canvas. No opaque background, no coloration matching wanted. It’s a easy solution to make the 3D scene really feel prefer it lives on the web page moderately 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 moderately than digital.
The Setting provides ambient and directional lights.
6. Mouse interplay
I needed the fashions to react subtly to the cursor, nothing difficult. A single mousemove listener shops the offset from the middle of the display. 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)
// ... similar 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: a better 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, go away, and enter hooks. The Expertise is created as soon as and reused for the entire session.
// fundamental.js
barba.init({
preventRunning: true,
forestall: ({ 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 digital camera to the present namespace. go away 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 digital camera to the brand new namespace and run transitionIn. The digital camera 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 strains so we will stagger the animation, which feels far more natural than animating the entire block without delay. For transitionOut, we animate titles and textual content strains upward (yPercent: -100), fade them out, and scale spacers to zero. The spacers use transformOrigin: 'proper heart' on go away so that they shrink towards the precise; on enter we use 'left heart' so that they develop from the left. Small element, nevertheless it makes the course really feel intentional.
// animations.js
export perform transitionOut(knowledge) {
return new Promise((resolve) => {
const container = knowledge?.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: 'strains', linesClass: 'text-line' })
textLines = break up.strains ?? null
}
gsap.timeline({ onComplete: () => resolve() })
.to(titleElements, {
yPercent: -100,
opacity: 0,
length: 0.8,
ease: 'power4.in',
stagger: 0.2,
})
.to(
textLines && textLines.size ? textLines : textElement,
{
opacity: 0,
yPercent: -100,
length: 0.8,
ease: 'power4.in',
stagger: 0.1,
},
0
)
.to(spacerElements, {
scaleX: 0,
length: 0.8,
ease: 'power4.in',
transformOrigin: 'proper heart'
}, 0)
})
}
For transitionIn, we set preliminary states (components beneath the fold with yPercent: 100, spacers at scaleX: 0 with transformOrigin: 'left heart') 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 strains and spacers so the content material cascades in moderately than showing unexpectedly.
The digital camera 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 perform animateCameraToNamespace(namespace, expertise = null) {
if (!expertise?.digital camera?.occasion) return
const targetX = cameraPositionsByNamespace[namespace] ?? 0
gsap.to(expertise.digital camera.occasion.place, {
x: targetX,
length: 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 decreased 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.type.place = 'absolute'
bottomText.type.prime = '0'
bottomText.type.left = '0'
bottomText.type.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', length: 0.8, ease: 'power4.out' })
gsap.to(chars, { yPercent: -100, length: 0.8, stagger: 0.02, ease: 'power4.out' })
gsap.to(bottomChars, { yPercent: 0, length: 0.8, stagger: 0.02, ease: 'power4.out' })
})
button.addEventListener('mouseleave', () => {
gsap.to(button, { borderRadius: '0.25rem', length: 0.8, ease: 'power4.out' })
gsap.to(chars, { yPercent: 0, length: 0.8, stagger: 0.02, ease: 'power4.out' })
gsap.to(bottomChars, { yPercent: 100, length: 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 reply to structure adjustments. Moderately 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 digital camera side ratio and the renderer dimension. The pixel ratio can also 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 the whole 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 a Assets utility that makes use of Three.js’s GLTFLoader with DRACOLoader for compression. Once we want a mannequin within the scene, we clone its scene moderately than loading it once more. DRACO compression retains the file sizes small, price enabling in case you’re transport GLBs to manufacturing.
Last consequence
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 digital camera slides between fashions as you navigate. Mouse-reactive fashions add a little bit of life, and the loader plus reduced-motion help hold issues polished.
A fast observe on usability & accessibility
Movement sensitivity. Respect the consumer’s prefers-reduced-motion setting. We’ve achieved it for the button animations; you can prolong the identical method to web page transitions, both skip the GSAP animations for an instantaneous swap, or use very quick durations. A matchMedia test at first 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 must be navigable and readable by assistive tech.
What’s subsequent
You would prolong the scene to really feel like actual areas: room geometry, props, digital camera paths on web page change. Or hold the digital camera mounted and animate/swap fashions as an alternative. Or tie digital camera place to scroll for a parallax-like impact. Swap the GLB fashions to your personal, regulate cameraPositionsByNamespace to match, tweak easings and stagger values.
Thanks for following alongside! I’m excited to see what you’ll create. You probably have any questions, be happy to drop me a line.









