@@ -162,6 +162,102 @@ const pageMotion = {
162162const hasTag = ( profile : SkillProfile , tags : FrictionTag [ ] ) =>
163163 tags . some ( ( tag ) => profile . likedTags . includes ( tag ) || profile . dislikedTags . includes ( tag ) )
164164
165+ function useGsapScrollMotion ( stage : AppStage ) {
166+ useEffect ( ( ) => {
167+ const reduceMotion = window . matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches
168+ if ( reduceMotion ) return
169+
170+ let context : { revert : ( ) => void } | undefined
171+ let cancelled = false
172+
173+ async function mountScrollMotion ( ) {
174+ const [ { default : gsap } , { ScrollTrigger } ] = await Promise . all ( [
175+ import ( 'gsap' ) ,
176+ import ( 'gsap/ScrollTrigger' ) ,
177+ ] )
178+ if ( cancelled ) return
179+
180+ gsap . registerPlugin ( ScrollTrigger )
181+
182+ context = gsap . context ( ( ) => {
183+ gsap . set ( document . documentElement , { '--scroll-progress' : 0 } )
184+ gsap . to ( document . documentElement , {
185+ '--scroll-progress' : 1 ,
186+ ease : 'none' ,
187+ scrollTrigger : {
188+ end : 'bottom bottom' ,
189+ scrub : 0.35 ,
190+ start : 'top top' ,
191+ trigger : document . body ,
192+ } ,
193+ } )
194+
195+ gsap . utils . toArray < HTMLElement > ( '[data-scroll-reveal]' ) . forEach ( ( element ) => {
196+ gsap . fromTo (
197+ element ,
198+ { autoAlpha : 0 , filter : 'blur(10px)' , y : 34 } ,
199+ {
200+ autoAlpha : 1 ,
201+ duration : 0.8 ,
202+ ease : 'power3.out' ,
203+ filter : 'blur(0px)' ,
204+ scrollTrigger : {
205+ start : 'top 88%' ,
206+ toggleActions : 'play none none reverse' ,
207+ trigger : element ,
208+ } ,
209+ y : 0 ,
210+ } ,
211+ )
212+ } )
213+
214+ gsap . utils . toArray < HTMLElement > ( '[data-scroll-parallax]' ) . forEach ( ( element ) => {
215+ const depth = Number ( element . dataset . scrollParallax || 8 )
216+ gsap . to ( element , {
217+ ease : 'none' ,
218+ scrollTrigger : {
219+ end : 'bottom top' ,
220+ scrub : 0.6 ,
221+ start : 'top bottom' ,
222+ trigger : element ,
223+ } ,
224+ yPercent : - depth ,
225+ } )
226+ } )
227+
228+ gsap . utils . toArray < HTMLElement > ( '[data-scroll-card]' ) . forEach ( ( element , index ) => {
229+ gsap . fromTo (
230+ element ,
231+ { autoAlpha : 0 , scale : 0.965 , y : 28 } ,
232+ {
233+ autoAlpha : 1 ,
234+ delay : Math . min ( index * 0.035 , 0.18 ) ,
235+ duration : 0.64 ,
236+ ease : 'power2.out' ,
237+ scale : 1 ,
238+ scrollTrigger : {
239+ start : 'top 92%' ,
240+ toggleActions : 'play none none reverse' ,
241+ trigger : element ,
242+ } ,
243+ y : 0 ,
244+ } ,
245+ )
246+ } )
247+
248+ ScrollTrigger . refresh ( )
249+ } , document . body )
250+ }
251+
252+ void mountScrollMotion ( )
253+
254+ return ( ) => {
255+ cancelled = true
256+ context ?. revert ( )
257+ }
258+ } , [ stage ] )
259+ }
260+
165261const scoreLabel = ( score : number ) => {
166262 if ( score >= 78 ) return 'Strong signal'
167263 if ( score >= 58 ) return 'Active signal'
@@ -283,6 +379,9 @@ function App() {
283379 profile ,
284380 recommendations ,
285381 ] )
382+
383+ useGsapScrollMotion ( stage )
384+
286385 useEffect ( ( ) => {
287386 const state : SavedState = { answers, currentIndex, dislikedGameId, topGames, stage }
288387 window . localStorage . setItem ( storageKey , JSON . stringify ( state ) )
@@ -432,10 +531,11 @@ function App() {
432531 < motion . main className = "results-stage" { ...pageMotion } >
433532 < VantaClimbScene progress = { 100 } stage = "results" />
434533 < AmbientSnowfield progress = { 100 } variant = "results" />
534+ < ScrollAscentIndicator />
435535 < InlineUtility resetQuiz = { resetQuiz } />
436536
437537 < motion . section className = "results-reveal" variants = { staggerContainer } initial = "initial" animate = "animate" >
438- < motion . div className = "reveal-copy" variants = { staggerItem } >
538+ < motion . div className = "reveal-copy" variants = { staggerItem } data-scroll-parallax = "5" >
439539 < span className = "completion-kicker" > Quiz complete</ span >
440540 < h1 >
441541 You lean { axisCopy [ profile . dominant ] . label } , with a { axisCopy [ profile . secondary ] . label } backup plan.
@@ -456,7 +556,7 @@ function App() {
456556 </ div >
457557 </ motion . div >
458558
459- < motion . div className = "reveal-profile-panel" variants = { staggerItem } >
559+ < motion . div className = "reveal-profile-panel" variants = { staggerItem } data-scroll-parallax = "9" >
460560 < ProfileDiagram scores = { profile . scores } />
461561 < div className = "reveal-axis-stack" >
462562 < div className = "confidence-pill" >
@@ -468,7 +568,7 @@ function App() {
468568 </ motion . div >
469569 </ motion . section >
470570
471- < motion . section className = "quick-read-section" variants = { staggerContainer } initial = "initial" animate = "animate" >
571+ < motion . section className = "quick-read-section" variants = { staggerContainer } initial = "initial" animate = "animate" data-scroll-reveal >
472572 < Reveal className = "quick-read-card primary" >
473573 < span > Pressure you like</ span >
474574 < h2 > { axisResultCopy [ profile . dominant ] . pressure } </ h2 >
@@ -483,7 +583,7 @@ function App() {
483583 </ Reveal >
484584 </ motion . section >
485585
486- < section className = "recommendations-section" id = "recommendations" aria-label = "Recommended games" >
586+ < section className = "recommendations-section" id = "recommendations" aria-label = "Recommended games" data-scroll-reveal >
487587 < div className = "recommendation-heading" >
488588 < div >
489589 < h2 > Your best next plays</ h2 >
@@ -505,7 +605,7 @@ function App() {
505605 </ motion . div >
506606 </ section >
507607
508- < section className = "deep-results-section" aria-label = "Deeper profile details" >
608+ < section className = "deep-results-section" aria-label = "Deeper profile details" data-scroll-reveal >
509609 < Button
510610 className = "deep-results-toggle"
511611 variant = "outline"
@@ -687,6 +787,15 @@ function InlineUtility({ resetQuiz }: { resetQuiz: () => void }) {
687787 )
688788}
689789
790+ function ScrollAscentIndicator ( ) {
791+ return (
792+ < div className = "scroll-ascent-indicator" aria-hidden = "true" >
793+ < span />
794+ < i />
795+ </ div >
796+ )
797+ }
798+
690799function MotivationMeter ( { label, value, copy } : { label : string ; value : number ; copy : string } ) {
691800 return (
692801 < div className = "motivation-meter" >
@@ -733,7 +842,7 @@ function RecommendationCard({ recommendation, rank }: { recommendation: Recommen
733842 const topAxis = axes . toSorted ( ( a , b ) => recommendation . game . axes [ b ] - recommendation . game . axes [ a ] ) [ 0 ]
734843
735844 return (
736- < LiftCard variants = { staggerItem } >
845+ < LiftCard variants = { staggerItem } data-scroll-card >
737846 < Card className = "game-card" data-axis = { topAxis } >
738847 < div className = "game-card-constellation" aria-hidden = "true" >
739848 { axes . map ( ( axis ) => (
@@ -840,14 +949,15 @@ function OnboardingStage({
840949 return (
841950 < motion . main className = "onboarding-stage" { ...pageMotion } >
842951 < VantaClimbScene progress = { 0 } stage = "landing" />
952+ < ScrollAscentIndicator />
843953 < div className = "ice-mountain-scene" aria-hidden = "true" >
844954 < span className = "ice-peak peak-one" />
845955 < span className = "ice-peak peak-two" />
846956 < span className = "ice-peak peak-three" />
847957 </ div >
848958
849959 < section className = "onboarding-shell" aria-label = "GameFit top games onboarding" >
850- < div className = "onboarding-intro" >
960+ < div className = "onboarding-intro" data-scroll-parallax = "6" >
851961 < div className = "mock-brand" aria-label = "GameFit" >
852962 < img src = { gamefitIcon } alt = "" />
853963 < strong > GameFit</ strong >
@@ -874,7 +984,7 @@ function OnboardingStage({
874984 </ a >
875985 </ div >
876986
877- < Card className = "mock-onboarding-card" >
987+ < Card className = "mock-onboarding-card" data-scroll-parallax = "10" >
878988 < CardContent >
879989 < div className = "mock-games-column" >
880990 < div className = "mock-panel-title" >
0 commit comments