diff --git a/src/components/landing/ChartsCatalogGallery.tsx b/src/components/landing/ChartsCatalogGallery.tsx index 4203819c2..0007d3dc4 100644 --- a/src/components/landing/ChartsCatalogGallery.tsx +++ b/src/components/landing/ChartsCatalogGallery.tsx @@ -6,6 +6,7 @@ import { ChartsCatalogChart, type ChartsCatalogModuleReference, } from '~/components/charts/ChartsCatalogChart' +import { useInView } from '~/hooks/useInView' import type { ChartsCatalogCase } from '~/utils/charts-catalog' type CatalogCase = Pick< @@ -49,7 +50,7 @@ export function CatalogChartsHero({ ) const [focused, setFocused] = React.useState(false) const [hovered, setHovered] = React.useState(false) - const [inView, setInView] = React.useState(true) + const inView = useInView(rootRef, { threshold: 0.2 }) const [pageVisible, setPageVisible] = React.useState(true) const [paused, setPaused] = React.useState(false) const reducedMotion = useReducedMotion() @@ -77,18 +78,6 @@ export function CatalogChartsHero({ return () => intervals.forEach((interval) => window.clearInterval(interval)) }, [orderedCases.length, running, visibleTileCount]) - React.useEffect(() => { - const root = rootRef.current - if (!root || !('IntersectionObserver' in window)) return - - const observer = new IntersectionObserver( - ([entry]) => setInView(Boolean(entry?.isIntersecting)), - { threshold: 0.2 }, - ) - observer.observe(root) - return () => observer.disconnect() - }, []) - React.useEffect(() => { const updateVisibility = () => setPageVisible(document.visibilityState === 'visible') diff --git a/src/components/landing/ChartsLandingGraphics.tsx b/src/components/landing/ChartsLandingGraphics.tsx index f90e035ab..a16befe1c 100644 --- a/src/components/landing/ChartsLandingGraphics.tsx +++ b/src/components/landing/ChartsLandingGraphics.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { Pause, Play, Shuffle } from '@phosphor-icons/react' import { useIsDark } from '~/hooks/useIsDark' +import { useInView } from '~/hooks/useInView' import kineticBarChartSource from '../../../scripts/charts-landing/kinetic-bar-chart.ts?raw' import kineticDonutChartSource from '../../../scripts/charts-landing/kinetic-donut-chart.ts?raw' @@ -377,7 +378,7 @@ export function KineticChartsHero() { const [outgoingIndex, setOutgoingIndex] = React.useState(null) const [focusWithin, setFocusWithin] = React.useState(false) const [hovered, setHovered] = React.useState(false) - const [inView, setInView] = React.useState(true) + const inView = useInView(rootRef, { threshold: 0.18 }) const [autoAdvanceOverride, setAutoAdvanceOverride] = React.useState(false) const [pageVisible, setPageVisible] = React.useState(true) const [paused, setPaused] = React.useState(false) @@ -438,20 +439,6 @@ export function KineticChartsHero() { return () => window.clearInterval(interval) }, [autoPlay, interacting, showNewVariation]) - React.useEffect(() => { - const root = rootRef.current - if (!root || !('IntersectionObserver' in window)) { - return - } - - const observer = new IntersectionObserver( - ([entry]) => setInView(Boolean(entry?.isIntersecting)), - { threshold: 0.18 }, - ) - observer.observe(root) - return () => observer.disconnect() - }, []) - React.useEffect(() => { const updateVisibility = () => setPageVisible(document.visibilityState === 'visible') diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts new file mode 100644 index 000000000..e5562a8d9 --- /dev/null +++ b/src/hooks/useInView.ts @@ -0,0 +1,31 @@ +import * as React from 'react' + +export function useInView( + targetRef: React.RefObject, + { + root = null, + rootMargin = '0px', + threshold = 0, + }: IntersectionObserverInit = {}, +) { + const [inView, setInView] = React.useState(false) + + React.useEffect(() => { + const target = targetRef.current + if (!target) return + + if (!('IntersectionObserver' in window)) { + setInView(true) + return + } + + const observer = new IntersectionObserver( + ([entry]) => setInView(Boolean(entry?.isIntersecting)), + { root, rootMargin, threshold }, + ) + observer.observe(target) + return () => observer.disconnect() + }, [root, rootMargin, targetRef, threshold]) + + return inView +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 0a743eaad..c3426989d 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -20,10 +20,12 @@ import { HomeSocialProofSection } from '~/components/home/HomeSocialProofSection import { HomeStatsSection } from '~/components/home/HomeStatsSection' import { useQuery } from '@tanstack/react-query' import { Button, Eyebrow } from '~/components/ds/ui' +import { useInView } from '~/hooks/useInView' import { useNpmDownloadCounter } from '~/hooks/useNpmDownloadCounter' import { homepageNpmStatsSummaryQuery, ossStatsQuery } from '~/queries/stats' import { useLibrariesOverlay } from '~/contexts/LibrariesOverlayContext' import { fetchRecentPosts } from '~/utils/blog.functions' +import { usePrefersReducedMotion } from '~/utils/usePrefersReducedMotion' import { seo } from '~/utils/seo' export const Route = createFileRoute('/')({ @@ -565,8 +567,13 @@ function FrameworkAdapterGraph({ }) { const [activeAdapterIndex, setActiveAdapterIndex] = React.useState(0) const [flowProgress, setFlowProgress] = React.useState(0) + const rootRef = React.useRef(null) + const isVisible = useInView(rootRef) + const prefersReducedMotion = usePrefersReducedMotion() React.useEffect(() => { + if (!isVisible || prefersReducedMotion !== false) return + const intervalId = window.setInterval(() => { setActiveAdapterIndex( (currentIndex) => (currentIndex + 1) % frameworkAdapterNodes.length, @@ -574,12 +581,10 @@ function FrameworkAdapterGraph({ }, 1150) return () => window.clearInterval(intervalId) - }, []) + }, [isVisible, prefersReducedMotion]) React.useEffect(() => { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { - return - } + if (!isVisible || prefersReducedMotion !== false) return let frameId = 0 let lastUpdate = 0 @@ -597,13 +602,14 @@ function FrameworkAdapterGraph({ frameId = window.requestAnimationFrame(update) return () => window.cancelAnimationFrame(frameId) - }, []) + }, [isVisible, prefersReducedMotion]) return ( // The node positions below are hard-coded against a 320×128 grid // (adapterGraphWidth/Height), so the whole graph is scaled as a unit to // fill the 460×233 slot rather than re-deriving every coordinate.