refactor(landing): extract the scroll-progress hook the previews all copied

Nine preview components on the landing page each carried their own copy of the
same effect: measure the container against the viewport, clamp the ratio to
0..1, keep it in state, and attach passive scroll + resize listeners. Two of
them also duplicated the effect that measures how far a list can travel.

useScrollProgress() and useScrollTranslate() now own that, and the components
just read the progress. welcome.tsx loses 265 lines.

Verified in the browser: all nine previews still animate as the page scrolls
(the eight transform/opacity ones and the cashflow chart, which animates bar
heights). Adds unit coverage for the clamped progress, which nothing tested.
This commit is contained in:
Víctor Falcón 2026-08-11 13:52:27 +02:00
parent 026e61cd6e
commit d41336c710
3 changed files with 182 additions and 293 deletions

View File

@ -0,0 +1,62 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useScrollProgress } from './use-scroll-progress';
/**
* Place the element's box so that the hook sees it at a known position, then
* fire the scroll listener the hook attached.
*/
function positionElement(
element: HTMLElement | null,
{ top, height }: { top: number; height: number },
) {
vi.spyOn(element as HTMLElement, 'getBoundingClientRect').mockReturnValue({
top,
height,
} as DOMRect);
act(() => {
window.dispatchEvent(new Event('scroll'));
});
}
describe('useScrollProgress', () => {
beforeEach(() => {
window.innerHeight = 800;
});
it('stays at 0 until the element enters from the bottom', () => {
const { result } = renderHook(() =>
useScrollProgress<HTMLDivElement>(),
);
result.current.ref.current = document.createElement('div');
// Element still below the fold: top beyond the viewport height.
positionElement(result.current.ref.current, { top: 900, height: 200 });
expect(result.current.progress).toBe(0);
});
it('reaches 1 once the element has passed above the viewport', () => {
const { result } = renderHook(() =>
useScrollProgress<HTMLDivElement>(),
);
result.current.ref.current = document.createElement('div');
positionElement(result.current.ref.current, { top: -400, height: 200 });
expect(result.current.progress).toBe(1);
});
it('reports how far through the viewport the element has travelled', () => {
const { result } = renderHook(() =>
useScrollProgress<HTMLDivElement>(),
);
result.current.ref.current = document.createElement('div');
// (800 - 400) / (800 + 200) = 0.4
positionElement(result.current.ref.current, { top: 400, height: 200 });
expect(result.current.progress).toBeCloseTo(0.4);
});
});

View File

@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
/**
* How far an element has travelled through the viewport: 0 before it enters from
* the bottom, 1 once it has left through the top. Drives the scroll-linked
* animations in the landing page previews.
*/
export function useScrollProgress<T extends HTMLElement>() {
const [progress, setProgress] = useState(0);
const ref = useRef<T | null>(null);
useEffect(() => {
const updateProgress = () => {
const element = ref.current;
if (!element) {
return;
}
const rect = element.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const next =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setProgress(Math.min(1, Math.max(0, next)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
return { ref, progress };
}
interface UseScrollTranslateOptions {
/** Fraction of the scrollable distance the list moves per unit of progress. */
speed: number;
/** Extra travel past the overflow so the last row clears the container. */
padding: number;
}
/**
* A list that scrolls itself as the page scrolls past it: returns the refs to
* wire up plus how far the list should be translated.
*/
export function useScrollTranslate<
C extends HTMLElement,
L extends HTMLElement,
>({ speed, padding }: UseScrollTranslateOptions) {
const { ref: containerRef, progress } = useScrollProgress<C>();
const listRef = useRef<L | null>(null);
const [maxTranslate, setMaxTranslate] = useState(0);
useEffect(() => {
const updateMaxTranslate = () => {
const container = containerRef.current;
const list = listRef.current;
if (!container || !list) {
return;
}
setMaxTranslate(
Math.max(
0,
list.scrollHeight - container.clientHeight + padding,
),
);
};
updateMaxTranslate();
window.addEventListener('resize', updateMaxTranslate);
return () => {
window.removeEventListener('resize', updateMaxTranslate);
};
}, [containerRef, padding]);
return {
containerRef,
listRef,
progress,
translateY: progress * maxTranslate * speed,
};
}

View File

@ -7,6 +7,10 @@ import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { tailwindColorClasses } from '@/components/user-info';
import { usePwaInstall } from '@/hooks/use-pwa-install';
import {
useScrollProgress,
useScrollTranslate,
} from '@/hooks/use-scroll-progress';
import { readStoredValue, writeStoredValue } from '@/lib/safe-storage';
import { cn } from '@/lib/utils';
import { type SharedData } from '@/types';
@ -37,7 +41,7 @@ import {
WrenchIcon,
XIcon,
} from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
const LANDING_IMAGES = [
{
@ -280,66 +284,16 @@ function BankConnectionsPreview({
prependFromBottomCount?: number;
className?: string;
}) {
const [translateY, setTranslateY] = useState(0);
const [maxTranslate, setMaxTranslate] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const scrollSpeed = 0.15;
const { containerRef, listRef, translateY } = useScrollTranslate<
HTMLDivElement,
HTMLUListElement
>({ speed: 0.15, padding: 24 });
const prependedBanksCount = Math.min(prependFromBottomCount, banks.length);
const previewBanks = useMemo(
() => [...banks.slice(-prependedBanksCount), ...banks],
[banks, prependedBanksCount],
);
useEffect(() => {
const updateMaxTranslate = () => {
const container = containerRef.current;
const list = listRef.current;
if (!container || !list) {
return;
}
const travelDistance = Math.max(
0,
list.scrollHeight - container.clientHeight + 24,
);
setMaxTranslate(travelDistance);
};
updateMaxTranslate();
window.addEventListener('resize', updateMaxTranslate);
return () => {
window.removeEventListener('resize', updateMaxTranslate);
};
}, []);
useEffect(() => {
const updateTranslate = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
const clampedProgress = Math.min(1, Math.max(0, progress));
setTranslateY(clampedProgress * maxTranslate * scrollSpeed);
};
updateTranslate();
window.addEventListener('scroll', updateTranslate, { passive: true });
window.addEventListener('resize', updateTranslate);
return () => {
window.removeEventListener('scroll', updateTranslate);
window.removeEventListener('resize', updateTranslate);
};
}, [maxTranslate, scrollSpeed]);
return (
<div
ref={containerRef}
@ -397,13 +351,16 @@ function TransactionRowsPreview({
currency: string;
locale: string;
}) {
const [translateY, setTranslateY] = useState(0);
const [maxTranslate, setMaxTranslate] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const {
containerRef,
listRef,
translateY,
progress: scrollProgress,
} = useScrollTranslate<HTMLDivElement, HTMLUListElement>({
speed: 0.28,
padding: 56,
});
const prependedRowsCount = Math.min(10, TRANSACTION_PREVIEW_ROWS.length);
const scrollSpeed = 0.28;
const previewRows = useMemo(
() => [
...TRANSACTION_PREVIEW_ROWS.slice(-prependedRowsCount),
@ -412,56 +369,6 @@ function TransactionRowsPreview({
[prependedRowsCount],
);
useEffect(() => {
const updateMaxTranslate = () => {
const container = containerRef.current;
const list = listRef.current;
if (!container || !list) {
return;
}
const travelDistance = Math.max(
0,
list.scrollHeight - container.clientHeight + 56,
);
setMaxTranslate(travelDistance);
};
updateMaxTranslate();
window.addEventListener('resize', updateMaxTranslate);
return () => {
window.removeEventListener('resize', updateMaxTranslate);
};
}, []);
useEffect(() => {
const updateTranslate = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
const clampedProgress = Math.min(1, Math.max(0, progress));
setScrollProgress(clampedProgress);
setTranslateY(clampedProgress * maxTranslate * scrollSpeed);
};
updateTranslate();
window.addEventListener('scroll', updateTranslate, { passive: true });
window.addEventListener('resize', updateTranslate);
return () => {
window.removeEventListener('scroll', updateTranslate);
window.removeEventListener('resize', updateTranslate);
};
}, [maxTranslate, scrollSpeed]);
return (
<div
ref={containerRef}
@ -569,36 +476,12 @@ function AccountsBalancePreview({
currency: string;
locale: string;
}) {
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
const cardCount = ACCOUNT_PREVIEW_ROWS.length;
const staggerDelay = 0.12;
const staggerRange = 1 - (cardCount - 1) * staggerDelay;
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
return (
<div
ref={containerRef}
@ -707,8 +590,8 @@ function ImportPreview({
currency: string;
locale: string;
}) {
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
// How many transaction rows to show below the file card
const previewRows = TRANSACTION_PREVIEW_ROWS.slice(0, 5);
@ -720,30 +603,6 @@ function ImportPreview({
// Row i starts appearing at this scroll progress
const rowStart = (i: number) => 0.3 + i * 0.1;
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
// File card animation: drops from above, scales and fades in
const fileProgress = Math.min(1, scrollProgress / FILE_DROP_END);
const fileY = -80 + 92 * fileProgress;
@ -861,38 +720,14 @@ function PrivacyRedactedPreview() {
{ label: 'Statement', value: 'Q4 2024 · ' + __('Quartely') },
] as const;
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
// Each bar slides in over this span of scroll progress
const BAR_SPAN = 0.15;
// Row i's bar starts sliding at this scroll progress
const barStart = (i: number) => 0.1 + i * 0.12;
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
return (
<div
ref={containerRef}
@ -962,32 +797,8 @@ const CASHFLOW_PREVIEW_DATA = [
] as const;
function CashflowChartPreview() {
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
const maxValue = Math.max(
...CASHFLOW_PREVIEW_DATA.map((d) =>
@ -1131,38 +942,14 @@ function BudgetsListPreview({
currency: string;
locale: string;
}) {
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
const ROW_SLIDE_SPAN = 0.15;
const BAR_FILL_SPAN = 0.2;
const rowSlideStart = (i: number) => 0.05 + i * 0.1;
const barFillStart = (i: number) => rowSlideStart(i) + 0.12;
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
return (
<div
ref={containerRef}
@ -1272,8 +1059,8 @@ function BudgetDetailPreview({
currency: string;
locale: string;
}) {
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
// Grocery budget detail — 68% spent
const budget = BUDGETS_PREVIEW_ROWS[0];
@ -1287,30 +1074,6 @@ function BudgetDetailPreview({
const ROW_STEP = 0.1;
const ROW_SPAN = 0.18;
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
const barFill =
Math.min(1, Math.max(0, scrollProgress / BAR_SPAN)) * spentPct * 100;
const statsProgress = Math.min(
@ -1448,8 +1211,8 @@ function BudgetDetailPreview({
}
function BudgetEditPreview() {
const [scrollProgress, setScrollProgress] = useState(0);
const containerRef = useRef<HTMLDivElement | null>(null);
const { ref: containerRef, progress: scrollProgress } =
useScrollProgress<HTMLDivElement>();
// "Dining Out" budget — 90% spent (high alert)
const budget = BUDGETS_PREVIEW_ROWS[2]; // Entertainment, 90%
@ -1462,30 +1225,6 @@ function BudgetEditPreview() {
const ACTION_START = 0.35;
const ACTION_SPAN = 0.18;
useEffect(() => {
const updateProgress = () => {
const container = containerRef.current;
if (!container) {
return;
}
const rect = container.getBoundingClientRect();
const viewportHeight = window.innerHeight || 1;
const progress =
(viewportHeight - rect.top) / (viewportHeight + rect.height);
setScrollProgress(Math.min(1, Math.max(0, progress)));
};
updateProgress();
window.addEventListener('scroll', updateProgress, { passive: true });
window.addEventListener('resize', updateProgress);
return () => {
window.removeEventListener('scroll', updateProgress);
window.removeEventListener('resize', updateProgress);
};
}, []);
const rowProgress = Math.min(1, Math.max(0, scrollProgress / ROW_SPAN));
const alertProgress = Math.min(
1,