Skip to content

Commit ff4ef23

Browse files
Add GSAP scroll ascent motion
1 parent 0037415 commit ff4ef23

2 files changed

Lines changed: 193 additions & 8 deletions

File tree

src/App.css

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5405,3 +5405,78 @@
54055405
grid-template-columns: 1fr;
54065406
}
54075407
}
5408+
5409+
/* GSAP + Vanta scroll layer. */
5410+
:root {
5411+
--scroll-progress: 0;
5412+
}
5413+
5414+
.scroll-ascent-indicator {
5415+
position: fixed;
5416+
top: 20vh;
5417+
right: clamp(28px, 5vw, 72px);
5418+
z-index: 6;
5419+
width: 2px;
5420+
height: 30vh;
5421+
pointer-events: none;
5422+
background: rgb(255 255 255 / 0.2);
5423+
border-radius: 999px;
5424+
box-shadow: 0 0 0 1px rgb(8 17 31 / 0.04);
5425+
overflow: hidden;
5426+
}
5427+
5428+
.scroll-ascent-indicator span {
5429+
position: absolute;
5430+
inset: 0;
5431+
display: block;
5432+
background: linear-gradient(180deg, rgb(255 255 255 / 0.94), rgb(125 211 252 / 0.72), rgb(8 17 31 / 0.42));
5433+
border-radius: inherit;
5434+
filter: drop-shadow(0 0 12px rgb(255 255 255 / 0.42));
5435+
transform: scaleY(var(--scroll-progress));
5436+
transform-origin: top;
5437+
}
5438+
5439+
.scroll-ascent-indicator i {
5440+
position: absolute;
5441+
top: calc((100% - 8px) * var(--scroll-progress));
5442+
left: 50%;
5443+
display: block;
5444+
width: 8px;
5445+
height: 8px;
5446+
background: #f8fcff;
5447+
border-radius: 999px;
5448+
box-shadow: 0 0 18px rgb(255 255 255 / 0.86);
5449+
transform: translateX(-50%);
5450+
}
5451+
5452+
[data-scroll-reveal],
5453+
[data-scroll-card],
5454+
[data-scroll-parallax] {
5455+
will-change: opacity, transform, filter;
5456+
}
5457+
5458+
.recommendation-grid [data-scroll-card]:nth-child(3n + 2) {
5459+
transform-origin: 50% 45%;
5460+
}
5461+
5462+
@media (max-width: 760px) {
5463+
.scroll-ascent-indicator {
5464+
top: auto;
5465+
right: 14px;
5466+
bottom: 18px;
5467+
height: 92px;
5468+
opacity: 0.74;
5469+
}
5470+
}
5471+
5472+
@media (prefers-reduced-motion: reduce) {
5473+
.scroll-ascent-indicator {
5474+
display: none;
5475+
}
5476+
5477+
[data-scroll-reveal],
5478+
[data-scroll-card],
5479+
[data-scroll-parallax] {
5480+
will-change: auto;
5481+
}
5482+
}

src/App.tsx

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,102 @@ const pageMotion = {
162162
const 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+
165261
const 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+
690799
function 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

Comments
 (0)