-
Notifications
You must be signed in to change notification settings - Fork 0
feat(home): scroll gravity toward completed acts #208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,224 @@ | ||
| import * as React from 'react' | ||
| import { afterEach, describe, expect, it, vi } from 'vitest' | ||
| import { act, cleanup, render } from '@testing-library/react' | ||
| import type { MotionValue } from 'motion/react' | ||
|
|
||
| const animateMock = vi.hoisted(() => | ||
| vi.fn( | ||
| ( | ||
| _from: number, | ||
| to: number, | ||
| options?: { onUpdate?: (value: number) => void } | ||
| ) => { | ||
| options?.onUpdate?.(to) | ||
| return { stop: vi.fn() } | ||
| } | ||
| ) | ||
| ) | ||
|
|
||
| vi.mock('motion/react', () => ({ | ||
| animate: animateMock, | ||
| })) | ||
|
|
||
| import { | ||
| ACT_HOLDS, | ||
| CATCH_RADIUS, | ||
| DEAD_ZONE, | ||
| nearestHold, | ||
| useActGravity, | ||
| } from './act-gravity' | ||
|
|
||
| afterEach(() => { | ||
| cleanup() | ||
| vi.useRealTimers() | ||
| animateMock.mockClear() | ||
| vi.restoreAllMocks() | ||
| vi.unstubAllGlobals() | ||
| }) | ||
|
|
||
| describe('nearestHold', () => { | ||
| it('pulls toward a hold from inside the catch radius', () => { | ||
| expect(nearestHold(0.4 + CATCH_RADIUS * 0.6)).toBe(0.4) | ||
| expect(nearestHold(0.64 - CATCH_RADIUS * 0.6)).toBe(0.64) | ||
| }) | ||
|
|
||
| it('lets transitions rest where they are outside the radius', () => { | ||
| expect(nearestHold(0.2)).toBeNull() | ||
| expect(nearestHold(0.52)).toBeNull() | ||
| expect(nearestHold(0.8)).toBeNull() | ||
| }) | ||
|
|
||
| it('does not re-trigger once settled on a plateau (dead zone)', () => { | ||
| for (const hold of ACT_HOLDS) { | ||
| expect(nearestHold(hold)).toBeNull() | ||
| expect(nearestHold(hold + DEAD_ZONE / 2)).toBeNull() | ||
| } | ||
| }) | ||
|
|
||
| it('picks the nearer hold when two are conceivable', () => { | ||
| expect(nearestHold(0.6)).toBe(0.64) | ||
| }) | ||
| }) | ||
|
|
||
| function GravityHarness({ progress }: { progress: MotionValue<number> }) { | ||
| const scrollerRef = React.useRef<HTMLDivElement>(null) | ||
| useActGravity(scrollerRef, progress) | ||
|
|
||
| return React.createElement('div', { | ||
| ref: scrollerRef, | ||
| 'data-testid': 'gravity-scroller', | ||
| }) | ||
| } | ||
|
|
||
| function removeScrollEndSupport() { | ||
| const windowDescriptor = Object.getOwnPropertyDescriptor(window, 'onscrollend') | ||
| const prototypeDescriptor = Object.getOwnPropertyDescriptor( | ||
| Window.prototype, | ||
| 'onscrollend' | ||
| ) | ||
|
|
||
| delete (window as unknown as Record<string, unknown>).onscrollend | ||
| delete (Window.prototype as unknown as Record<string, unknown>).onscrollend | ||
|
|
||
| return () => { | ||
| if (windowDescriptor) { | ||
| Object.defineProperty(window, 'onscrollend', windowDescriptor) | ||
| } | ||
| if (prototypeDescriptor) { | ||
| Object.defineProperty(Window.prototype, 'onscrollend', prototypeDescriptor) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| function stubReducedMotion(matches: boolean) { | ||
| vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }))) | ||
| } | ||
|
|
||
| function stubScrollerGeometry(height: number) { | ||
| const offsetHeightDescriptor = Object.getOwnPropertyDescriptor( | ||
| HTMLElement.prototype, | ||
| 'offsetHeight' | ||
| ) | ||
| Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { | ||
| configurable: true, | ||
| get: () => height, | ||
| }) | ||
| vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue( | ||
| new DOMRect(0, 0, 0, height) | ||
| ) | ||
| vi.spyOn(window, 'innerHeight', 'get').mockReturnValue(1000) | ||
| vi.spyOn(window, 'scrollY', 'get').mockReturnValue(0) | ||
|
|
||
| return () => { | ||
| if (offsetHeightDescriptor) { | ||
| Object.defineProperty( | ||
| HTMLElement.prototype, | ||
| 'offsetHeight', | ||
| offsetHeightDescriptor | ||
| ) | ||
| return | ||
| } | ||
|
|
||
| delete (HTMLElement.prototype as unknown as Record<string, unknown>) | ||
| .offsetHeight | ||
| } | ||
| } | ||
|
|
||
| function createProgress(value: number) { | ||
| return { | ||
| get: vi.fn(() => value), | ||
| } as unknown as MotionValue<number> | ||
| } | ||
|
|
||
| describe('useActGravity', () => { | ||
| it('settles near a hold after fallback scrolling stops', () => { | ||
| vi.useFakeTimers() | ||
| const restoreScrollEnd = removeScrollEndSupport() | ||
| const restoreGeometry = stubScrollerGeometry(2000) | ||
| const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) | ||
| const progress = createProgress(ACT_HOLDS[0] + CATCH_RADIUS / 2) | ||
|
|
||
| try { | ||
| render(React.createElement(GravityHarness, { progress })) | ||
|
|
||
| act(() => { | ||
| window.dispatchEvent(new Event('scroll')) | ||
| vi.advanceTimersByTime(141) | ||
| }) | ||
|
|
||
| const target = ACT_HOLDS[0] * 1000 | ||
| expect(animateMock).toHaveBeenCalledWith( | ||
| 0, | ||
| target, | ||
| expect.objectContaining({ duration: 0.55 }) | ||
| ) | ||
| expect(scrollTo).toHaveBeenCalledWith(0, target) | ||
| } finally { | ||
| restoreGeometry() | ||
| restoreScrollEnd() | ||
| } | ||
| }) | ||
|
|
||
| it('settles through the scrollend-supported path', () => { | ||
| vi.stubGlobal('onscrollend', null) | ||
| const restoreGeometry = stubScrollerGeometry(2000) | ||
| const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {}) | ||
| const progress = createProgress(ACT_HOLDS[1] - CATCH_RADIUS / 2) | ||
|
|
||
| try { | ||
| render(React.createElement(GravityHarness, { progress })) | ||
|
|
||
| act(() => { | ||
| window.dispatchEvent(new Event('scrollend')) | ||
| }) | ||
|
|
||
| const target = ACT_HOLDS[1] * 1000 | ||
| expect(animateMock).toHaveBeenCalledWith( | ||
| 0, | ||
| target, | ||
| expect.objectContaining({ duration: 0.55 }) | ||
| ) | ||
| expect(scrollTo).toHaveBeenCalledWith(0, target) | ||
| } finally { | ||
| restoreGeometry() | ||
| } | ||
| }) | ||
|
|
||
| it('returns early when reduced motion is requested', () => { | ||
| vi.useFakeTimers() | ||
| stubReducedMotion(true) | ||
| vi.stubGlobal('onscrollend', null) | ||
| const progress = createProgress(ACT_HOLDS[0] + CATCH_RADIUS / 2) | ||
|
|
||
| render(React.createElement(GravityHarness, { progress })) | ||
|
|
||
| act(() => { | ||
| window.dispatchEvent(new Event('scrollend')) | ||
| window.dispatchEvent(new Event('scroll')) | ||
| vi.advanceTimersByTime(141) | ||
| }) | ||
|
|
||
| expect(progress.get).not.toHaveBeenCalled() | ||
| expect(animateMock).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('cancels a pending fallback debounce when the user starts interacting', () => { | ||
| vi.useFakeTimers() | ||
| const restoreScrollEnd = removeScrollEndSupport() | ||
| const progress = createProgress(0.2) | ||
|
|
||
| try { | ||
| render(React.createElement(GravityHarness, { progress })) | ||
|
|
||
| act(() => { | ||
| window.dispatchEvent(new Event('scroll')) | ||
| window.dispatchEvent(new Event('touchstart')) | ||
| vi.advanceTimersByTime(141) | ||
| }) | ||
|
|
||
| expect(progress.get).not.toHaveBeenCalled() | ||
| } finally { | ||
| restoreScrollEnd() | ||
| } | ||
| }) | ||
| }) |
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| 'use client' | ||
|
|
||
| import * as React from 'react' | ||
| import { animate, type MotionValue } from 'motion/react' | ||
|
|
||
| /** | ||
| * Scroll gravity for the pinned story (owner ask: completed acts should | ||
| * carry more weight than transitions). When scrolling comes to rest within | ||
| * the catch radius of an act's hold plateau, the last stretch eases to the | ||
| * plateau — the classic scrollytelling "settle assist" (GSAP ScrollTrigger | ||
| * calls it snap; CSS proximity snapping is the platform cousin, but its | ||
| * radius isn't tunable). Any user input cancels immediately; reduced motion | ||
| * disables it entirely. | ||
| */ | ||
|
|
||
| /** Progress positions where each act sits fully composed (see keyframes.ts: | ||
| * act 2 holds 0.32–0.44, act 3 holds 0.58–0.7, act 4 settles ≥0.92). Act 1 | ||
| * is the top of the page, which has its own natural gravity. */ | ||
| export const ACT_HOLDS = [0.4, 0.64, 0.95] as const | ||
|
|
||
| /** How far (in progress space) the pull reaches. 0.05 of a 560vh scroller | ||
| * ≈ a fifth of a viewport — noticeable, never grabby. */ | ||
| export const CATCH_RADIUS = 0.05 | ||
|
|
||
| /** Dead zone so a settle that lands on the plateau doesn't re-trigger. */ | ||
| export const DEAD_ZONE = 0.004 | ||
|
|
||
| export function nearestHold(progress: number): number | null { | ||
| let best: number | null = null | ||
| for (const hold of ACT_HOLDS) { | ||
| const distance = Math.abs(progress - hold) | ||
| if (distance >= CATCH_RADIUS || distance <= DEAD_ZONE) continue | ||
| if (best === null || distance < Math.abs(progress - best)) best = hold | ||
| } | ||
| return best | ||
| } | ||
|
|
||
| export function useActGravity( | ||
| scrollerRef: React.RefObject<HTMLElement | null>, | ||
| progress: MotionValue<number> | ||
| ) { | ||
| React.useEffect(() => { | ||
| const scroller = scrollerRef.current | ||
| if (!scroller) return | ||
| if ( | ||
| typeof window.matchMedia === 'function' && | ||
| window.matchMedia('(prefers-reduced-motion: reduce)').matches | ||
| ) { | ||
| return | ||
| } | ||
|
|
||
| let animation: ReturnType<typeof animate> | null = null | ||
| let settling = false | ||
| let debounce: ReturnType<typeof setTimeout> | undefined | ||
|
|
||
| const cancel = () => { | ||
| clearTimeout(debounce) | ||
| debounce = undefined | ||
| animation?.stop() | ||
| animation = null | ||
| settling = false | ||
| } | ||
|
wcpos-bot[bot] marked this conversation as resolved.
|
||
|
|
||
| const settle = () => { | ||
| if (settling) return | ||
| const hold = nearestHold(progress.get()) | ||
| if (hold === null) return | ||
| const top = scroller.getBoundingClientRect().top + window.scrollY | ||
| const target = top + hold * (scroller.offsetHeight - window.innerHeight) | ||
| if (Math.abs(target - window.scrollY) < 2) return | ||
| settling = true | ||
| animation = animate(window.scrollY, target, { | ||
| duration: 0.55, | ||
| ease: [0.22, 0.61, 0.36, 1], | ||
| onUpdate: (value) => window.scrollTo(0, value), | ||
| onComplete: () => { | ||
| // let the scroll events from our own settle drain before re-arming | ||
| requestAnimationFrame(() => { | ||
| settling = false | ||
| }) | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| // scrollend where available; debounced scroll as the fallback | ||
| const hasScrollEnd = 'onscrollend' in window | ||
| const onScrollEnd = () => { | ||
| if (!settling) settle() | ||
| } | ||
| const onScroll = () => { | ||
| if (settling) return | ||
| clearTimeout(debounce) | ||
| debounce = setTimeout(settle, 140) | ||
| } | ||
| if (hasScrollEnd) window.addEventListener('scrollend', onScrollEnd) | ||
| else window.addEventListener('scroll', onScroll, { passive: true }) | ||
|
|
||
| // the user always wins: any input releases the pull instantly | ||
| const onInput = () => cancel() | ||
| window.addEventListener('wheel', onInput, { passive: true }) | ||
| window.addEventListener('touchstart', onInput, { passive: true }) | ||
| window.addEventListener('keydown', onInput) | ||
|
|
||
| return () => { | ||
| cancel() | ||
| clearTimeout(debounce) | ||
| window.removeEventListener('scrollend', onScrollEnd) | ||
| window.removeEventListener('scroll', onScroll) | ||
| window.removeEventListener('wheel', onInput) | ||
| window.removeEventListener('touchstart', onInput) | ||
| window.removeEventListener('keydown', onInput) | ||
| } | ||
| }, [scrollerRef, progress]) | ||
| } | ||
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
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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.