DEV Community

A0mineTV
A0mineTV

Posted on

GSAP + Vue 3: A buttery-smooth RAF loop for parallax (Nuxt-friendly)

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
Enter fullscreen mode Exit fullscreen mode

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 },
  }
})
Enter fullscreen mode Exit fullscreen mode

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)
  })
}
Enter fullscreen mode Exit fullscreen mode
  1. 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>
Enter fullscreen mode Exit fullscreen mode

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 quickTo works 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)