reasonable animation system

This commit is contained in:
2026-03-13 19:42:22 +01:00
parent 166ae50c49
commit e0e32d16e2
7 changed files with 93 additions and 55 deletions

View File

@@ -1,12 +1,14 @@
import { useGSAP } from "@gsap/react"; import { useGSAP } from "@gsap/react";
import { useRef, type ReactNode } from "react"; import { useLayoutEffect,
useRef, type ReactNode } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer"; import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText"; import { SplitText } from "gsap/SplitText";
import gsap from 'gsap' import gsap from 'gsap'
const AnimateTextIn = ({children,animation="type"}:{children:ReactNode,animation?:"type"|"slide",index?:gsap.Position}) => { const AnimateTextIn = ({children,animation="type",position}:{children:ReactNode,animation?:"type"|"slide",position:gsap.Position}) => {
const el = useRef<HTMLDivElement>(null) const el = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext(); const gsapContext = useGsapContext();
useGSAP(() => { useGSAP(() => {
console.log("aniamte text with:",position)
const tl = gsap.timeline(); const tl = gsap.timeline();
const chars = new SplitText(el.current,{type:'chars'}) const chars = new SplitText(el.current,{type:'chars'})
tl.to(el.current,{opacity:100, duration:0}) tl.to(el.current,{opacity:100, duration:0})
@@ -18,8 +20,8 @@ const AnimateTextIn = ({children,animation="type"}:{children:ReactNode,animation
tl.from(chars.chars,{opacity:0, duration: 0.01, stagger: {each: 0.04}, ease: 'bounce.inOut', scrollTrigger: el.current }) tl.from(chars.chars,{opacity:0, duration: 0.01, stagger: {each: 0.04}, ease: 'bounce.inOut', scrollTrigger: el.current })
break break
} }
gsapContext?.addAnimation(tl) gsapContext?.addAnimation(tl,position)
},{scope:el}) })
return ( return (
<div ref={el} className="opacity-0"> <div ref={el} className="opacity-0">
{children} {children}

View File

@@ -1,24 +1,25 @@
import { useGSAP } from "@gsap/react"; import { useRef } from "react"; import { useGSAP } from "@gsap/react"; import { useEffect, useLayoutEffect, useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer"; import { useGsapContext } from "~/app/_providers/GsapProvicer";
import { SplitText } from "gsap/SplitText"; import { SplitText } from "gsap/SplitText";
import gsap from 'gsap' import gsap from 'gsap'
const AnimatedPageTitle = ( const AnimatedPageTitle = (
{ text }: { text: string } { text, position }: { text: string, position:gsap.Position }
) => { ) => {
const el = useRef<HTMLHeadingElement>(null) const el = useRef<HTMLHeadingElement>(null)
const gsapContext = useGsapContext(); const gsapContext = useGsapContext();
useGSAP(() => { useGSAP(() => {
console.log("add animated title with:",position)
const tl = gsap.timeline(); const tl = gsap.timeline();
tl.addLabel("title") tl.addLabel("title")
const split = new SplitText(el.current, { type: "chars" }) const split = new SplitText(el.current, { type: "chars" })
tl.to(el.current, { opacity: 100 }) tl.from(el.current, { opacity: 0 })
tl.from(split.chars, { tl.from(split.chars, {
stagger: 0.05, rotate: -90, opacity: 0, x: -10 stagger: 0.05, rotate: -90, opacity: 0, x: -10
}, '>') }, '>')
gsapContext?.addAnimation(tl) gsapContext?.addAnimation(tl,position)
}, { scope: el }) })
return ( return (
<h1 className="text-4xl opacity-0 font-bold text-balance w-full" ref={el}> {text} </h1> <h1 className="text-4xl opacity-100 font-bold text-balance w-full" ref={el}> {text} </h1>
) )
} }

View File

@@ -8,33 +8,43 @@ import { createContext, useCallback, useContext, useRef, useState, type ReactNod
gsap.registerPlugin(useGSAP) gsap.registerPlugin(useGSAP)
gsap.registerPlugin(ScrollTrigger) gsap.registerPlugin(ScrollTrigger)
gsap.registerPlugin(SplitText) gsap.registerPlugin(SplitText)
const GsapContext = createContext<{ addAnimation: (animation: gsap.core.TimelineChild) => void, resetTimeline: () => void} | null>(null) const GsapContext = createContext<{
addAnimation: (
animation: gsap.core.TimelineChild,
position: gsap.Position
) => void,
resetTimeline: () => void,
resumeTimeline: () => void
} | null>(null)
export function useGsapContext() { export function useGsapContext() {
return useContext(GsapContext) return useContext(GsapContext)
} }
export default function GsapProvider({ children }: { children: ReactNode }) { export default function GsapProvider({ children }: { children: ReactNode }) {
const [tl, setTl] = useState<GSAPTimeline | undefined>(); const tl = useRef<gsap.core.Timeline | null>(null)
const indexRef = useRef<number>(0)
const { contextSafe } = useGSAP(() => { const { contextSafe } = useGSAP(() => {
console.log("App effect (create timeline)"); if (!tl.current) {
const tl = gsap.timeline(); tl.current = gsap.timeline({ paused: true })
setTl(() => tl); }
}, []); console.log("resuming timeline:",tl.current)
return () => { console.log("gsap cleanup") }
const addAnimation = useCallback((animation: gsap.core.TimelineChild) => { })
indexRef.current += 1;
console.log(indexRef.current) const addAnimation = useCallback((animation: gsap.core.TimelineChild, position: gsap.Position) => {
tl && tl.add(animation, indexRef.current * 2); console.log("add animation to:", position)
}, [tl]); tl.current?.add(animation, position);
},[])
const resetTimeline = useCallback(() => { const resetTimeline = useCallback(() => {
const tl = gsap.timeline(); tl.current?.kill()
setTl(() => tl) tl.current?.revert()
indexRef.current = 0; tl.current = gsap.timeline({paused:true})
},[tl]) },[])
const resumeTimeline = useCallback(() => {
tl.current?.resume()
},[])
return ( return (
<GsapContext.Provider value={{ addAnimation, resetTimeline }}> <GsapContext.Provider value={{ addAnimation, resetTimeline,resumeTimeline }}>
{children} {children}
</GsapContext.Provider> </GsapContext.Provider>
) )

View File

@@ -33,11 +33,10 @@ export default function CvPage() {
} }
} }
useGSAP(() => { useGSAP(() => {
gsapContext?.resetTimeline()
const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan'); const items = gsap?.utils.toArray<GSAPTweenTarget>('.gsapan');
let dir = Direction.Left; let dir = Direction.Left;
items?.forEach(item => { items?.forEach(item => {
gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir))) gsapContext?.addAnimation(gsap.from(item, nextGsapConf(dir)),0)
if (dir == Direction.Down) { if (dir == Direction.Down) {
dir = Direction.Left dir = Direction.Left
} else { } else {

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useGSAP } from "@gsap/react"; import { useGSAP } from "@gsap/react";
import { useRef } from "react"; import { useLayoutEffect,
useRef } from "react";
import { trpc } from "~/app/_trpc/Client"; import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card"; import * as Card from "~/components/ui/card";
import { useGsapContext } from "../_providers/GsapProvicer"; import { useGsapContext } from "../_providers/GsapProvicer";
@@ -13,24 +14,17 @@ export default function MusicPage() {
const container = useRef<HTMLDivElement>(null) const container = useRef<HTMLDivElement>(null)
const gsapContext = useGsapContext(); const gsapContext = useGsapContext();
useGSAP(() => { useGSAP(() => {
gsapContext?.resetTimeline() gsapContext?.resumeTimeline()
const items = gsap.utils.toArray<HTMLElement>('.gsapan'); return () => {
const tl = gsap.timeline(); console.log("page cleanup")
items.map(item => { gsapContext?.resetTimeline()
const player = item.querySelector('.player'); }
tl.from( });
item, { x: -100, opacity: 0, duration: 0.5, ease: 'power2.inOut', scrollTrigger: item },'<'
).from(
player, { y: 10, opacity: 0, duration: 0.5, ease: 'power2.inOut' }
, '<0.3')
gsapContext?.addAnimation(tl);
})
}, { scope: container, dependencies: [isLoading] });
return (<> return (<>
<div ref={container} className="w-full h-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4"> <div ref={container} className="w-full h-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
<AnimatedPageTitle text="Just Some Music I Made"/> <AnimatedPageTitle position={0} text="Just Some Music I Made"/>
<AnimateTextIn> <AnimateTextIn position={0.5}>
<div className="flex flex-row h-8 content-center items-center"> <div className="flex flex-row h-8 content-center items-center">
<p className="mr-[1em]">All works on this page are licensed under:</p> <p className="mr-[1em]">All works on this page are licensed under:</p>
<a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a> <a href="https://creativecommons.org/licenses/by-nc-sa/4.0/">CC BY-NC-SA 4.0</a>
@@ -40,10 +34,10 @@ export default function MusicPage() {
<img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt=""/> <img className="max-w-[1em] ml-[1em]" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt=""/>
</div> </div>
</AnimateTextIn> </AnimateTextIn>
{tracks && tracks.map((track) => ( {tracks && tracks.map((track,i) => (
<Card.Card key={track.id} className='gsapan'> <Card.AnimatedCard key={track.id} position={i+1}>
<Card.CardHeader> <Card.CardHeader>
<AnimateTextIn animation="slide"> <AnimateTextIn position={i+1.2} animation="slide">
<Card.CardTitle>{track.title}</Card.CardTitle> <Card.CardTitle>{track.title}</Card.CardTitle>
</AnimateTextIn> </AnimateTextIn>
</Card.CardHeader> </Card.CardHeader>
@@ -55,7 +49,7 @@ export default function MusicPage() {
Your browser does not support the audio element. Your browser does not support the audio element.
</audio> </audio>
</Card.CardContent> </Card.CardContent>
</Card.Card> </Card.AnimatedCard>
))} ))}
{!isLoading && !tracks?.length && {!isLoading && !tracks?.length &&
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground"> <div className="flex justify-center items-center min-h-[200px] text-muted-foreground">

View File

@@ -1,5 +1,7 @@
import * as React from "react" import { useGSAP } from "@gsap/react";import * as React from "react"
import { useRef } from "react";
import { useGsapContext } from "~/app/_providers/GsapProvicer";
import gsap from 'gsap'
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils"
function Card({ function Card({
@@ -20,6 +22,35 @@ function Card({
) )
} }
function AnimatedCard({
className,
position = 0,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm", position: gsap.Position }) {
const gsapContext = useGsapContext()
const ref = useRef<HTMLDivElement|null>(null)
useGSAP(() => {
gsapContext?.addAnimation(gsap.from(ref.current,{
x: -100,
opacity: 0,
duration: 0.5
}),position)
})
return (
<div
ref={ref}
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl bg-opacity-60 backdrop-blur-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
@@ -100,4 +131,5 @@ export {
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
AnimatedCard
} }

View File

@@ -51,7 +51,7 @@
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { .dark {
--radius: 0; --radius: 0;
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823); --card-foreground: oklch(0.141 0.005 285.823);
@@ -86,7 +86,7 @@
--foreground: oklch(0.141 0.005 285.823); --foreground: oklch(0.141 0.005 285.823);
} }
.dark { :root {
--background: oklch(0.141 0.005 285.823); --background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885); --card: oklch(0.21 0.006 285.885);
@@ -139,4 +139,4 @@
* { * {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }