When you build a portfolio, you quickly realize something: animations are either delightful or distracting.
In this post, we’ll build a small but very real portfolio-style interaction: a pointer-driven parallax hero that feels smooth and “alive”, powered by GSAP and a requestAnimationFrame-style loop.
The goal is not “cool effects”. The goal is:
- smoothness
- control
- cleanup (no memory leaks)
- Nuxt SSR safety
- reduced-motion support
Why this approach?
When you build a portfolio, animations are either delightful or distracting. Most parallax effects feel "janky" because they calculate styles too slowly or fail to clean up properly after page navigation.
The goal of this guide is to achieve:
- Smoothness: Inertia-driven movement.
- Control: A centralized frame loop.
- Cleanup: No memory leaks during Nuxt route changes.
- Nuxt SSR safety: Ensuring code only runs where the DOM is available.
1) Install GSAP
npm i gsap
That’s it.
2) Nuxt 3 setup (SSR-safe)
In Nuxt, you must ensure GSAP only runs on the client (DOM required).
Option A — Keep it simple: import GSAP where you use it
This is what I do most of the time. We’ll use:
import { gsap } from "gsap"if (import.meta.client) { ... }
✅ Minimal, no plugin needed.
Option B — Nuxt plugin (optional)
Create plugins/gsap.client.ts:
import { gsap } from "gsap"
export default defineNuxtPlugin(() => {
return {
provide: { gsap },
}
})
Then you can access it via const { $gsap } = useNuxtApp().
In this article, I’ll stick to Option A.
3) The composable: a GSAP-driven “RAF loop”
Create composables/useParallaxTicker.ts:
import { gsap } from "gsap"
import { onBeforeUnmount } from "vue"
type ParallaxLayer = {
el: HTMLElement
strength: number
}
export function useParallaxTicker(layers: ParallaxLayer[], options?: { ease?: number }) {
const ease = options?.ease ?? 0.12
let targetX = 0
let targetY = 0
let currentX = 0
let currentY = 0
// quickSetter is extremely fast for frequent updates
const setters = layers.map((layer) => ({
x: gsap.quickSetter(layer.el, "x", "px"),
y: gsap.quickSetter(layer.el, "y", "px"),
strength: layer.strength,
}))
function onPointerMove(e: PointerEvent) {
const cx = window.innerWidth / 2
const cy = window.innerHeight / 2
// Normalize to [-1, 1]
targetX = (e.clientX - cx) / cx
targetY = (e.clientY - cy) / cy
}
function tick() {
// simple inertia / smoothing
currentX += (targetX - currentX) * ease
currentY += (targetY - currentY) * ease
for (const s of setters) {
s.x(currentX * s.strength)
s.y(currentY * s.strength)
}
}
// start
window.addEventListener("pointermove", onPointerMove, { passive: true })
gsap.ticker.add(tick)
// cleanup
onBeforeUnmount(() => {
window.removeEventListener("pointermove", onPointerMove)
gsap.ticker.remove(tick)
})
}
- The Logic: Why gsap.ticker ?
Instead of a standard requestAnimationFrame, we use GSAP’s ticker. It acts as a managed loop that ensures:
• Consistent timing with other GSAP animations.
• Easy lifecycle management: Simple methods to add or remove listeners.
• Integration: It plays perfectly when you also use timelines.
4) The Vue component
Create components/HeroParallax.vue:
<template>
<section class="hero">
<div class="layers" ref="root">
<div class="layer layer--back" ref="layerBack"></div>
<div class="layer layer--mid" ref="layerMid"></div>
<div class="layer layer--front" ref="layerFront">
<h1>Hi, I’m Vincent.</h1>
<p>Nuxt / Vue developer building tools & fast experiences.</p>
<a class="cta" href="#work">See my work</a>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue"
import { gsap } from "gsap"
import { useParallaxTicker } from "@/composables/useParallaxTicker"
const root = ref<HTMLElement | null>(null)
const layerBack = ref<HTMLElement | null>(null)
const layerMid = ref<HTMLElement | null>(null)
const layerFront = ref<HTMLElement | null>(null)
function prefersReducedMotion() {
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false
}
onMounted(() => {
if (!import.meta.client) return
if (!layerBack.value || !layerMid.value || !layerFront.value) return
// Respect reduced motion
if (prefersReducedMotion()) return
// Optional: intro animation
gsap.from(
[
layerFront.value.querySelector("h1"),
layerFront.value.querySelector("p"),
layerFront.value.querySelector(".cta"),
],
{
y: 12,
opacity: 0,
duration: 0.6,
stagger: 0.08,
ease: "power2.out",
}
)
useParallaxTicker(
[
{ el: layerBack.value, strength: 14 },
{ el: layerMid.value, strength: 24 },
{ el: layerFront.value, strength: 34 },
],
{ ease: 0.10 }
)
})
</script>
<style scoped>
.hero {
min-height: 70vh;
display: grid;
place-items: center;
overflow: hidden;
padding: 4rem 1.5rem;
}
.layers {
position: relative;
width: min(980px, 92vw);
height: 420px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.10);
overflow: hidden;
}
.layer {
position: absolute;
inset: 0;
will-change: transform;
transform: translate3d(0, 0, 0);
}
.layer--back {
background: radial-gradient(circle at 20% 20%, rgba(255, 255, 255, 0.10), transparent 55%);
filter: blur(10px);
}
.layer--mid {
background: radial-gradient(circle at 80% 40%, rgba(255, 255, 255, 0.12), transparent 55%);
filter: blur(6px);
}
.layer--front {
display: grid;
align-content: center;
gap: 0.75rem;
padding: 3rem;
}
h1 {
font-size: clamp(2rem, 4vw, 3rem);
margin: 0;
}
p {
opacity: 0.85;
margin: 0;
max-width: 52ch;
}
.cta {
display: inline-flex;
width: fit-content;
padding: 0.75rem 1rem;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
text-decoration: none;
}
</style>
5) Performance notes (the “why this feels good” part)
A parallax loop can become laggy very quickly. This implementation uses three intentional optimization choices:
✅ gsap.quickSetter()
Updating CSS transforms every frame (60fps+) is common, but expensive. quickSetter is a direct "fast path" to update a property without the overhead of the full GSAP core engine on every tick.
✅ Transform Only (No Layout)
We only update x and y transforms. We avoid top or left properties because they trigger "reflows" (expensive layout recalculations).
✅ Hardware Acceleration
Adding will-change: transform in your CSS hints to the browser to prepare the GPU, preventing unexpected stutters.
6) Common improvements (portfolio-ready)
Here are easy upgrades you can add:
- Clamp movement to avoid large shifts on ultrawide screens
- Pause parallax when the hero is off-screen (IntersectionObserver)
- Add “magnetic” CTA hover (GSAP
quickToworks great) - Tie parallax to scroll instead of pointer (ScrollTrigger)
Final thoughts
If you’re building a Nuxt/Vue portfolio, GSAP is a great fit because it gives you:
- high performance updates
- powerful timelines
- reliable lifecycle control (mount/unmount)
- a clean path to more advanced interactions
If you want, I can also provide a second version of this demo using:
- ScrollTrigger (parallax tied to scroll)
- gsap.context() for scoped selectors and automatic cleanup
- a small “magnetic button” micro-interaction for the CTA
Top comments (0)