A production-ready, directive-based page transition system for Nuxt 4 using GSAP and SplitText.
- π― Manual Control - No auto-detection, full control via Vue directives
- π¨ 4 Animation Types - Split text, fade, clip-path, and stagger animations
- β‘ GSAP Powered - Hardware-accelerated, smooth 60fps animations
- β¨ SplitText Integration - Character, word, and line reveals with masking
- π ScrollSmoother - Buttery smooth scrolling with parallax effects (data-speed, data-lag)
- π Opposite Animations - Elements animate OUT, then IN with opposite effects
- π Simple & DRY - Two composables, 4 directives, zero complexity
- π¦ Easy to Reuse - Copy 8 files and you're done
See the system in action with all 4 animation types. Navigate between pages to see smooth OUT/IN transitions.
Quick Example:
<!-- Page transitions + ScrollSmoother parallax -->
<h1 v-page-split:chars data-speed="0.8">Animated Title</h1>
<p v-page-fade:up data-speed="1.2">Fades up with faster scroll</p>
<div v-page-clip:top data-lag="0.2">Clips from top with smooth lag</div>
<ul v-page-stagger data-speed="0.9">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>Copy these 8 files to your Nuxt 4 project:
your-project/
βββ app/
β βββ composables/
β β βββ usePageTransition.js # Page transition logic
β β βββ useScrollSmootherManager.js # ScrollSmoother manager
β βββ directives/
β β βββ v-page-split.js # SplitText directive
β β βββ v-page-fade.js # Fade directive
β β βββ v-page-clip.js # Clip-path directive
β β βββ v-page-stagger.js # Stagger directive
β βββ plugins/
β β βββ page-transitions.js # Register directives (SSR-compatible)
β βββ layouts/
β βββ default.vue # Layout with ScrollSmoother + transitions
npm install gsap @hypernym/nuxt-gsapAdd GSAP module to nuxt.config.ts:
export default defineNuxtConfig({
modules: ["@hypernym/nuxt-gsap"],
gsap: {
composables: true,
provide: true,
// Club GreenSock premium plugins (FREE as of 2025!)
clubPlugins: {
splitText: true, // Character/word/line text splitting
scrollSmoother: true, // Smooth scrolling with parallax
},
// Extra plugins
extraPlugins: {
scrollTrigger: true, // Required for ScrollSmoother
},
},
});Create or update app/layouts/default.vue:
<script setup>
// Page transitions
const { leave, enter, beforeEnter, afterLeave } = usePageTransition();
// ScrollSmoother
const { createSmoother, killSmoother } = useScrollSmootherManager();
onMounted(() => {
nextTick(() => {
createSmoother({
wrapper: "#smooth-wrapper",
content: "#smooth-content",
smooth: 1, // Optimized for consistent 60fps across all browsers
effects: true, // Enable data-speed and data-lag
normalizeScroll: true, // Improves performance, especially on Safari
ignoreMobileResize: true, // Prevents janky resizing on mobile
});
});
});
onUnmounted(() => {
killSmoother();
});
</script>
<template>
<!-- ScrollSmoother wrapper -->
<div id="smooth-wrapper">
<div id="smooth-content">
<div class="layout-wrapper">
<!-- Your persistent nav/header here -->
<!-- Page content with transitions -->
<NuxtPage
:transition="{
name: 'page',
mode: 'out-in',
onBeforeEnter: beforeEnter,
onEnter: enter,
onLeave: leave,
onAfterLeave: afterLeave,
}"
/>
</div>
</div>
</div>
</template><template>
<div class="page-content">
<!-- Page transitions -->
<h1 v-page-split:chars data-speed="0.8">My Page</h1>
<p v-page-fade:up data-speed="1.2">Some text</p>
<!-- Parallax effects -->
<div v-page-clip:top data-lag="0.2" class="hero">
<h2>Hero Section</h2>
</div>
<ul v-page-stagger data-speed="0.9">
<li>Item 1</li>
<li>Item 2</li>
</ul>
</div>
</template>Done! Navigate between pages to see animations + scroll to see parallax effects.
Animates text using GSAP SplitText with character, word, or line splitting.
<!-- Arguments: :chars | :words | :lines -->
<h1 v-page-split:chars>Character reveal</h1>
<p v-page-split:words>Word by word</p>
<div v-page-split:lines>Line by line</div>
<!-- With custom config -->
<h1
v-page-split:chars="{ stagger: 0.04, duration: 0.8, ease: 'back.out(1.5)' }"
>
Custom timing
</h1>Options:
splitType- 'chars', 'words', or 'lines'stagger- Delay between each split element (default: 0.025)duration- Animation duration (default: 0.6)ease- GSAP easing function (default: 'back.out(1.5)')y- Vertical movement distance (default: 35)
Simple fade animation with optional directional movement.
<!-- Arguments: :up | :down | :left | :right -->
<div v-page-fade>Fade in (defaults to up)</div>
<p v-page-fade:up>Fade up</p>
<p v-page-fade:down>Fade down</p>
<div v-page-fade:left="{ distance: 40 }">Fade left with custom distance</div>Options:
direction- 'up', 'down', 'left', 'right'distance- Movement distance in pixels (default: 20)duration- Animation duration (default: 0.6)ease- GSAP easing function (default: 'power2.out')
Modern clip-path reveal animations from any direction.
<!-- Arguments: :top | :bottom | :left | :right -->
<div v-page-clip>Clip from top (default)</div>
<div v-page-clip:top>Clip from top</div>
<div v-page-clip:bottom>Clip from bottom</div>
<div v-page-clip:left="{ duration: 0.8 }">Clip from left</div>Options:
direction- 'top', 'bottom', 'left', 'right'duration- Animation duration (default: 0.6)ease- GSAP easing function (default: 'power2.out')
Stagger child elements with fade animation.
<ul v-page-stagger>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
<!-- Custom config -->
<div v-page-stagger="{ stagger: 0.15, duration: 0.7 }">
<div>Card 1</div>
<div>Card 2</div>
</div>
<!-- Custom selector for nested elements -->
<nav v-page-stagger="{ selector: 'a' }">
<a href="#">Link 1</a>
<a href="#">Link 2</a>
</nav>Options:
selector- CSS selector for children (default: ':scope > *')stagger- Delay between each child (default: 0.1)duration- Animation duration per child (default: 0.5)ease- GSAP easing function (default: 'power2.out')
Add data-speed and data-lag attributes to any element for smooth parallax effects.
Controls how fast elements move relative to scroll:
<!-- Slower than scroll (background effect) -->
<div data-speed="0.5">Moves at 50% scroll speed</div>
<h1 data-speed="0.8">Moves at 80% scroll speed</h1>
<!-- Normal speed (default) -->
<p data-speed="1.0">Moves at 100% scroll speed</p>
<!-- Faster than scroll (foreground effect) -->
<div data-speed="1.5">Moves at 150% scroll speed</div>
<img data-speed="2.0">Moves at 200% scroll speed</img>Values:
< 1.0- Background effect (slower than scroll)= 1.0- Normal scroll speed (default)> 1.0- Foreground effect (faster than scroll)
Creates a smooth "catch up" effect with momentum:
<!-- Subtle lag -->
<div data-lag="0.1">Slight trailing motion</div>
<!-- Medium lag -->
<h2 data-lag="0.2">Smooth catch-up effect</h2>
<!-- Heavy lag -->
<img data-lag="0.3">Pronounced trailing</img>Values:
- Typical range:
0.1to0.3 - Higher values = more lag/trailing
Use both page transitions AND parallax on the same elements:
<h1 v-page-split:chars data-speed="0.8">
Animated reveal + slow parallax
</h1>
<p v-page-fade:up data-lag="0.15">
Fade transition + lag effect
</p>
<div v-page-clip:top data-speed="1.2">
Clip animation + fast parallax
</div>-
Directives Store Config - When mounted, directives store animation config on elements:
el._pageAnimation = { type: 'split', config: { stagger: 0.025, ... } }
-
Page Transition Reads Config - During route changes,
usePageTransitionfinds all elements with_pageAnimationproperty -
GSAP Timelines Execute - Animation functions build GSAP timelines for smooth OUT/IN animations
User clicks link
β
LEAVE hook fires
β
Find elements with directives
β
Animate all elements OUT (fade up, clip away, etc.)
β
Old page removed
β
New page mounted
β
ENTER hook fires (with nextTick)
β
Find elements with directives
β
Animate all elements IN (opposite of OUT)
β
Done!
We use mode: 'out-in' so the old page completely animates out before the new page animates in. This creates a clean, professional transition without overlapping content.
Use any GSAP easing function:
<h1 v-page-split:chars="{ ease: 'elastic.out(1, 0.3)' }">Bouncy</h1>
<p v-page-fade:up="{ ease: 'power4.out' }">Smooth</p>Use different directives on the same page:
<template>
<div class="page-content">
<h1 v-page-split:chars>Title</h1>
<p v-page-fade:up>Subtitle</p>
<div v-page-clip:top>Content box</div>
<ul v-page-stagger>
<li>List item 1</li>
<li>List item 2</li>
</ul>
</div>
</template>Each page can have completely different animations:
<!-- home.vue -->
<h1 v-page-split:chars>Home</h1>
<p v-page-fade:up>Welcome</p>
<!-- about.vue -->
<h1 v-page-split:words>About</h1>
<p v-page-clip:bottom>Different style</p>-
Check directives are registered:
β Page transition directives registered! - v-page-split:chars | :words | :lines - v-page-fade:in | :out | :up | :down | :left | :right - v-page-clip:top | :bottom | :left | :right - v-page-stagger:fade | :clip | :scale -
Check layout setup - Make sure
usePageTransitionis called in your layout -
Check page structure - Directives must be on direct children of
.page-content
Make sure SplitText is enabled in nuxt.config.ts:
gsap: {
extraPlugins: {
splitText: true;
}
}This happens if GSAP properties aren't being cleared. Make sure afterLeave hook is being called.
The ENTER animation waits for nextTick() so directives have time to mount. If it's still not working, check console for "
Problem: On Safari (desktop and mobile), content jumps ~7px down when page transitions start.
Cause: SplitText with masking wraps each character/word/line in an overflow: hidden container, which adds ~7px to the element's height. Safari renders this layout shift visibly during the LEAVE transition.
Solution: The fix is built into animateSplit() in usePageTransition.js - it locks element height BEFORE SplitText runs:
// Lock height before SplitText to prevent Safari jump
const originalHeight = el.offsetHeight;
$gsap.set(el, { height: originalHeight });
// Now create split - can't grow because height is locked
const split = $SplitText.create(el, { type: splitType, mask: splitType });This prevents the element from growing when masks are added. The locked height is automatically cleared in afterLeave() with clearProps: 'all'.
Problem: Elements suddenly shift positions after page transitions.
Cause: Parallax effects are being applied after elements are visible.
Solution: The fix is already implemented in usePageTransition.js - effects are applied BEFORE animation:
// β
Correct timing (no jump)
refreshSmoother(); // Apply effects FIRST
const tl = $gsap.timeline({ onComplete: done });If you still see jumps, verify that refreshSmoother() is called before creating the timeline.
- Check
nuxt.config.tshas both plugins enabled:
clubPlugins: {
scrollSmoother: true;
}
extraPlugins: {
scrollTrigger: true; // Required
}- Verify wrapper elements in layout:
<div id="smooth-wrapper">
<div id="smooth-content">
<!-- Your content -->
</div>
</div>- Hardware Accelerated - Uses CSS transforms and opacity for 60fps
- Optimized for All Browsers - Settings tuned for consistent performance on Chrome, Safari, Firefox, and mobile
- Safari Optimized - Built-in fix for SplitText height jump issue
- Automatic Cleanup - SplitText instances are reverted after animations
- GSAP Timeline - Efficient timeline-based animation system
- Production Ready - Battle-tested animation patterns
The system uses optimized ScrollSmoother settings for consistent 60fps across all browsers:
{
smooth: 1, // Lower value = better performance
effects: true, // Enable data-speed and data-lag
normalizeScroll: true, // Significantly improves Safari performance
ignoreMobileResize: true // Prevents mobile jank
}Why smooth: 1?
- Higher values (like 2 or 3) look smoother but can drop to 14fps on Safari
smooth: 1maintains consistent 60fps across all browsers- Still provides the signature "buttery smooth" ScrollSmoother feel
Safari-Specific Issues Solved:
- β Content jump during transitions (SplitText height lock fix)
- β Slow 14fps scrolling (optimized smooth value + normalizeScroll)
- β Parallax effects not working after route change (refreshSmoother timing fix)
Works in all modern browsers that support:
- CSS clip-path
- CSS transforms
- GSAP 3.x
- Live Demo - See the system in action
MIT - Use it however you want!
Built with:
- GSAP - Industry-standard animation library
- Nuxt 4 - Vue framework
- SplitText - Premium GSAP plugin (FREE as of 2025)
- ScrollSmoother - Premium GSAP plugin (FREE as of 2025)