Last active
December 31, 2024 00:24
-
-
Save SuddenDev/e47544ff0114e4dcc3d48af6b57e52d0 to your computer and use it in GitHub Desktop.
Nuxt GSAP Page Transition Composable
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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. | |
}) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const useGlobalState = () => useState("useGlobalState", () => ({ | |
isPageTransitioning: false, | |
isInitialPageRequest: true, | |
hasPreloadingAnimation: false, | |
})) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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