95eb362bfc
QuantumLab template converted to Next.js 16 + React 19 + TypeScript: - 8 page routes (home, about, blog, contact, careers, team-members, coming-soon, 404) - Dynamic routes for blog posts, career positions, and team members - GSAP animations (marquee, counters, button hovers) - IntersectionObserver-based scroll reveal (blur-to-clear transitions) - Dark mode with next-themes - React Hook Form + Zod contact form - Framer Motion page transitions - Lottie animations via lottie-web Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
193 lines
6.5 KiB
TypeScript
193 lines
6.5 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect } from "react"
|
|
|
|
export default function GsapAnimations() {
|
|
useEffect(() => {
|
|
let ctx: ReturnType<typeof import("gsap").gsap.context> | null = null
|
|
|
|
import("gsap").then(async ({ gsap }) => {
|
|
const { ScrollTrigger } = await import("gsap/ScrollTrigger")
|
|
const { Observer } = await import("gsap/Observer")
|
|
gsap.registerPlugin(ScrollTrigger, Observer)
|
|
|
|
ctx = gsap.context(() => {
|
|
initMarquee(gsap, Observer)
|
|
initCounters(gsap, ScrollTrigger)
|
|
initScrollReveal(gsap, ScrollTrigger)
|
|
initButtonHovers(gsap)
|
|
initSmoothScroll()
|
|
})
|
|
})
|
|
|
|
return () => { ctx?.revert() }
|
|
}, [])
|
|
|
|
return null
|
|
}
|
|
|
|
function initMarquee(
|
|
gsap: typeof import("gsap").gsap,
|
|
Observer: typeof import("gsap/Observer").Observer,
|
|
) {
|
|
const marqueeItems = gsap.utils.toArray<HTMLElement>(".marquee-scroll-item")
|
|
if (!marqueeItems.length) return
|
|
|
|
const groups = new Map<HTMLElement, HTMLElement[]>()
|
|
marqueeItems.forEach((item) => {
|
|
const parent = item.parentElement
|
|
if (!parent) return
|
|
if (!groups.has(parent)) groups.set(parent, [])
|
|
groups.get(parent)!.push(item)
|
|
})
|
|
|
|
const instances: {
|
|
marqueeTimeline: gsap.core.Timeline
|
|
marqueeObject: { value: number }
|
|
hoverPauseProxy: { value: number }
|
|
isHoverPaused: boolean
|
|
lastDirection: number
|
|
}[] = []
|
|
|
|
groups.forEach((items, containerEl) => {
|
|
if (!items.length) return
|
|
const inst = {
|
|
marqueeObject: { value: 1 },
|
|
hoverPauseProxy: { value: 1 },
|
|
isHoverPaused: false,
|
|
lastDirection: 1,
|
|
marqueeTimeline: gsap.timeline({
|
|
repeat: -1,
|
|
onReverseComplete() { this.progress(1) },
|
|
}),
|
|
}
|
|
inst.marqueeTimeline.fromTo(items, { xPercent: 0 }, { xPercent: -100, duration: 50, ease: "none" })
|
|
|
|
const triggerSet = new Set<HTMLElement>()
|
|
if (containerEl.classList.contains("marquee-hover-stop")) triggerSet.add(containerEl)
|
|
containerEl.querySelectorAll<HTMLElement>(".marquee-hover-stop").forEach((el) => triggerSet.add(el))
|
|
|
|
if (triggerSet.size && window.matchMedia("(pointer: fine)").matches) {
|
|
const setPaused = (state: boolean) => {
|
|
inst.isHoverPaused = state
|
|
const ts = inst.marqueeTimeline.timeScale()
|
|
gsap.killTweensOf(inst.hoverPauseProxy)
|
|
gsap.fromTo(inst.hoverPauseProxy, { value: state ? ts : 0 }, {
|
|
value: state ? 0 : inst.lastDirection || 1,
|
|
duration: 0.4,
|
|
ease: "power2.out",
|
|
onUpdate: () => inst.marqueeTimeline.timeScale(inst.hoverPauseProxy.value),
|
|
})
|
|
}
|
|
triggerSet.forEach((el) => {
|
|
el.addEventListener("mouseenter", () => setPaused(true))
|
|
el.addEventListener("mouseleave", () => setPaused(false))
|
|
})
|
|
}
|
|
|
|
instances.push(inst)
|
|
})
|
|
|
|
if (instances.length) {
|
|
Observer.create({
|
|
target: window,
|
|
type: "wheel,scroll,touch",
|
|
onChangeY: (self: { velocityY: number }) => {
|
|
let velocity = gsap.utils.clamp(-40, 40, self.velocityY * 0.002)
|
|
const dir = velocity < 0 ? -1 : 1
|
|
instances.forEach((inst) => {
|
|
if (inst.isHoverPaused) return
|
|
inst.marqueeTimeline.timeScale(velocity)
|
|
inst.lastDirection = dir
|
|
gsap.fromTo(inst.marqueeObject, { value: velocity }, {
|
|
value: dir,
|
|
duration: 1,
|
|
ease: "power2.out",
|
|
onUpdate: () => { if (!inst.isHoverPaused) inst.marqueeTimeline.timeScale(inst.marqueeObject.value) },
|
|
})
|
|
})
|
|
},
|
|
})
|
|
}
|
|
|
|
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
instances.forEach((inst) => inst.marqueeTimeline.pause())
|
|
}
|
|
}
|
|
|
|
function initCounters(
|
|
gsap: typeof import("gsap").gsap,
|
|
ScrollTrigger: typeof import("gsap/ScrollTrigger").ScrollTrigger,
|
|
) {
|
|
const elements = gsap.utils.toArray<HTMLElement>(".count-up-number-animation")
|
|
if (!elements.length) return
|
|
|
|
elements.forEach((el, i) => {
|
|
const target = parseFloat(el.getAttribute("data-count") || "100")
|
|
const decimals = target % 1 !== 0 ? 1 : 0
|
|
gsap.fromTo(el, { textContent: "0" }, {
|
|
textContent: String(target),
|
|
duration: 2,
|
|
ease: "power1.out",
|
|
snap: decimals ? { textContent: 0.1 } : { textContent: 1 },
|
|
delay: i * 0.1,
|
|
scrollTrigger: { trigger: el, start: "top 80%", once: true },
|
|
onUpdate() {
|
|
const v = parseFloat(el.textContent || "0")
|
|
el.textContent = v.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
function initScrollReveal(
|
|
gsap: typeof import("gsap").gsap,
|
|
ScrollTrigger: typeof import("gsap/ScrollTrigger").ScrollTrigger,
|
|
) {
|
|
ScrollTrigger.batch(".animate-on-scroll", {
|
|
onEnter: (batch) => gsap.to(batch, { opacity: 1, y: 0, stagger: 0.15, overwrite: true }),
|
|
onLeave: (batch) => gsap.set(batch, { opacity: 0, y: 100, overwrite: true }),
|
|
onEnterBack: (batch) => gsap.to(batch, { opacity: 1, y: 0, stagger: 0.15, overwrite: true }),
|
|
onLeaveBack: (batch) => gsap.set(batch, { opacity: 0, y: -100, overwrite: true }),
|
|
})
|
|
}
|
|
|
|
function initButtonHovers(gsap: typeof import("gsap").gsap) {
|
|
document.querySelectorAll<HTMLElement>(".primary-button, .secondary-button, .footer-button").forEach((btn) => {
|
|
const icon = btn.querySelector<HTMLElement>(".button-icon-wrapper svg")
|
|
if (!icon) return
|
|
btn.addEventListener("mouseenter", () => {
|
|
gsap.to(icon, { x: 3, duration: 0.3, ease: "power2.out" })
|
|
})
|
|
btn.addEventListener("mouseleave", () => {
|
|
gsap.to(icon, { x: 0, duration: 0.3, ease: "power2.out" })
|
|
})
|
|
})
|
|
|
|
document.querySelectorAll<HTMLElement>(".footer-link, .footer-contact-link").forEach((link) => {
|
|
const bg = link.querySelector<HTMLElement>(".footer-link-bg, .footer-contact-link-bg")
|
|
if (!bg) return
|
|
link.addEventListener("mouseenter", () => {
|
|
gsap.to(bg, { width: "100%", duration: 0.3, ease: "power2.out" })
|
|
})
|
|
link.addEventListener("mouseleave", () => {
|
|
gsap.to(bg, { width: "0%", duration: 0.3, ease: "power2.out" })
|
|
})
|
|
})
|
|
|
|
document.querySelectorAll<HTMLElement>(".contact-v1-link").forEach((link) => {
|
|
const bg = link.querySelector<HTMLElement>(".contact-icon-bg")
|
|
if (!bg) return
|
|
link.addEventListener("mouseenter", () => {
|
|
gsap.to(bg, { width: "100%", duration: 0.4, ease: "power2.out" })
|
|
})
|
|
link.addEventListener("mouseleave", () => {
|
|
gsap.to(bg, { width: "0%", duration: 0.4, ease: "power2.out" })
|
|
})
|
|
})
|
|
}
|
|
|
|
function initSmoothScroll() {
|
|
document.documentElement.style.scrollBehavior = "smooth"
|
|
}
|