Skip to content

Instantly share code, notes, and snippets.

@SuddenDev
Last active December 31, 2024 00:24
Show Gist options
  • Save SuddenDev/e47544ff0114e4dcc3d48af6b57e52d0 to your computer and use it in GitHub Desktop.
Save SuddenDev/e47544ff0114e4dcc3d48af6b57e52d0 to your computer and use it in GitHub Desktop.
Nuxt GSAP Page Transition Composable
// You can use the composable different ways. As a function, you hook into the "onInit" hook. You can then also return a function that runs
// once the instance is destroyed (similar to gsap.context)
usePageAnimationContext(({ context, timeline, mmContext }) => {
// context = gsap.context
// timeline = a regular gsap timeline, that you can use to animate things in.
// mmContext = gsap.matchMedia (if you need to use that)
// optional
return () => {
// something here, that needs cleanup onBeforeUnmount
}
})
// you can also use the composable with an option. ctx is also "context, timeline, mmContext".
usePageAnimationContext({
scope, // scope is for the gsap.context scope
onInit: (ctx) => {}, // basically the setup before the pageTransition is completely done. Good to set up a gsap.from. Also works well with scrolltrigger animations.
onAnimate: (ctx) => {}, // runs after onInit and is basically when the page transition is finished.
onDestroy: (ctx) => {} // runs before the component / instance is destroyed. Called automatically on tryOnUnmounted.
})
// Just plug this as a prop into <NuxtPage>
import gsap from "gsap"
import type { TransitionProps } from "vue"
import { useGlobalState } from "~/composables/state"
const preTransition = () => {
useScroll().disableScroll()
useGlobalState().value.isPageTransitioning = true
}
const postTransition = () => {
useNuxtApp().callHook("page:animations:animate")
useScroll().enableScroll()
useGlobalState().value.isPageTransitioning = false
}
const pageTransition: TransitionProps = {
name: "page",
mode: "out-in",
css: false,
onBeforeLeave() {
preTransition()
},
onLeave: (el, done) => {
const preferredMotion = usePreferredReducedMotion()
if (preferredMotion.value === "reduce") done()
const page = el.querySelector(".page")
const slide = el.querySelector(".slide")
gsap.set(page, {
y: 0,
scale: 1,
})
gsap
.timeline({
paused: true,
onComplete: () => {
done()
} })
.to(page, {
y: -200,
scale: 0.9,
opacity: 0.5,
duration: 1.2,
ease: "slide",
})
.to(slide, {
y: 0,
duration: 1,
ease: "slide",
}, ">-1")
.play()
},
onEnter: async (el, done) => {
await useNuxtApp().callHook("page:animations:init")
let isAboveThreshold = false
const tl = gsap
.timeline({
paused: true,
onUpdate: () => {
if (!isAboveThreshold && tl.progress() >= 0.8) {
isAboveThreshold = true
done()
}
},
// onComplete: () => {
// done()
// },
})
.from(el, {
opacity: 0,
duration: 0.5,
ease: "slide",
})
.play()
},
onAfterEnter() {
postTransition()
},
onEnterCancelled: async () => {
await useNuxtApp().callHook("page:animations:destroy")
},
onLeaveCancelled: async () => {
await useNuxtApp().callHook("page:animations:destroy")
},
}
export default pageTransition
<script setup lang="ts">
// how to use it
import gsap from "gsap"
const section = ref()
usePageAnimationContext(({ context }) => {
context.add((self: gsap.Context) => {
const copyPs = self.selector!(".copy p")
copyPs.forEach((el: HTMLElement) => {
gsap.from(el, {
opacity: 0,
ease: "power3",
duration: 0.8,
y: 100,
scrollTrigger: {
trigger: el,
start: "clamp(top 80%)",
},
})
})
}, unrefElement(section)) // <-- Scope
})
</script>
export const useGlobalState = () => useState("useGlobalState", () => ({
isPageTransitioning: false,
isInitialPageRequest: true,
hasPreloadingAnimation: false,
}))
/* eslint-disable @typescript-eslint/no-invalid-void-type */
import { usePreferredReducedMotion, tryOnUnmounted, type MaybeElementRef } from "@vueuse/core"
import gsap from "gsap"
import { useGlobalState } from "./state"
export interface AnimationContext {
timeline: gsap.core.Timeline
mmContext: gsap.MatchMedia
context: gsap.Context
}
interface PageAnimationContextOptions {
scope?: MaybeElementRef
onInit?: (ctx: AnimationContext) => (() => void) | void
onAnimate?: (ctx: AnimationContext) => void
onDestroy?: (ctx: AnimationContext) => void
}
/**
* Manages the page animation context, including initialization, playing animations, and cleanup.
*
* @param {(context: AnimationContext) => void | PageAnimationContextOptions} [onInitOrOptions] - Either the function to call when the animation context is initialized or the options object.
* @param {PageAnimationContextOptions} [opts={}] - Options for configuring the animation context, if a function was passed as the first argument.
* @returns {Object} - Returns an object containing the animation context state and a destroy method.
*/
export function usePageAnimationContext(onInitOrOptions?: ((context: AnimationContext) => (() => void) | void) | PageAnimationContextOptions, opts: PageAnimationContextOptions = {}) {
const state = useGlobalState()
const route = useRoute()
const preferredMotion = usePreferredReducedMotion()
const prefersReducedMotion = computed(() => preferredMotion.value === "reduce")
// Determine if the first argument is a function or an options object
let onInit: ((context: AnimationContext) => (() => void) | void) | undefined = undefined
let onInitCleanup: (() => void) | undefined = undefined // Will hold the cleanup function from onInit
if (typeof onInitOrOptions === "function") {
onInit = onInitOrOptions as (context: AnimationContext) => (() => void) | void
}
else if (typeof onInitOrOptions === "object") {
opts = onInitOrOptions as PageAnimationContextOptions
if (opts.onInit && typeof opts.onInit === "function") onInit = opts.onInit
}
// If no function is provided, return only helper properties
if (!onInit) {
return {
isPageTransitioning: computed(() => state.value.isPageTransitioning),
prefersReducedMotion,
}
}
// Determine if animations should be prevented
const preventAnimation = computed(() => route.query?.__p || prefersReducedMotion.value)
const nuxtApp = useNuxtApp()
const isInitialized = ref(false)
const timeline = gsap.timeline({ paused: true })
let mmContext = gsap.matchMedia()
let context = gsap.context()
const animationCtx = computed<AnimationContext>(() => ({
timeline,
mmContext,
context,
}))
/**
* Initializes the animation context if it hasn't been initialized and animations are not prevented.
*/
const init = async () => {
if (preventAnimation.value || isInitialized.value) return
const element = opts.scope ? unrefElement(opts.scope) as Element : undefined
mmContext = gsap.matchMedia(element)
context = gsap.context(() => {}, element)
if (onInit) {
const cleanup = onInit(animationCtx.value)
if (typeof cleanup === "function") {
onInitCleanup = cleanup
}
}
isInitialized.value = true
}
/**
* Plays the timeline animation if animations are allowed.
*/
const animate = () => {
if (preventAnimation.value) return
opts.onAnimate?.(animationCtx.value)
}
/**
* Cleans up the animation context on component unmount or when necessary.
*/
const destroy = () => {
unregisterHooks()
// Call the onInit cleanup function if it exists
if (onInitCleanup) {
onInitCleanup()
}
mmContext.kill(true)
opts.onDestroy?.(animationCtx.value)
}
/** Unregister the animation lifecycle hooks */
const unregisterHooks = () => {
unregisterInitHook()
unregisterAnimateHook()
unregisterDestroyHook()
}
const unregisterInitHook = nuxtApp.hook("page:animations:init", init)
const unregisterAnimateHook = nuxtApp.hook("page:animations:animate", animate)
const unregisterDestroyHook = nuxtApp.hook("page:animations:destroy", destroy)
if (state.value.hasPreloadingAnimation || state.value.isInitialPageRequest) {
onMounted(async () => {
await init()
if (!state.value.hasPreloadingAnimation && state.value.isInitialPageRequest) {
animate()
}
})
}
/** When unmounting the component, this should clean up all contexts */
tryOnUnmounted(destroy)
return {
isPageTransitioning: computed(() => state.value.isPageTransitioning),
prefersReducedMotion,
destroy,
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment