From 6dbfab22374fd387b996eb186ca76d9a15086d50 Mon Sep 17 00:00:00 2001 From: MartinBraquet Date: Fri, 24 Jul 2026 21:07:39 +0200 Subject: [PATCH] Add `useCountUp` hook and integrate animated counting stats into About page - Introduced `useCountUp` hook to facilitate animated counting of numbers with easing effects. - Updated About page to display member, country, and conversation stats with count-up animations. - Added API data retrieval and Intersection Observer logic to trigger animations on scroll. - Replaced `GeneralButton` in About page with a custom `` for consistent styling and hover effects. - Enhanced visual hierarchy by applying animations and styles adapted for reduced-motion users. --- web/components/about/platform-stats.tsx | 180 ++++++++++++++++++++++-- web/hooks/use-count-up.ts | 52 +++++++ web/pages/about.tsx | 20 +-- 3 files changed, 234 insertions(+), 18 deletions(-) create mode 100644 web/hooks/use-count-up.ts diff --git a/web/components/about/platform-stats.tsx b/web/components/about/platform-stats.tsx index e7586795..785bf8ba 100644 --- a/web/components/about/platform-stats.tsx +++ b/web/components/about/platform-stats.tsx @@ -1,7 +1,9 @@ import clsx from 'clsx' -import {ReactNode} from 'react' +import {ReactNode, useEffect, useState} from 'react' import {eyebrow} from 'web/components/widgets/surface' import {useAPIGetter} from 'web/hooks/use-api-getter' +import {useCountUp} from 'web/hooks/use-count-up' +import {useSafeLayoutEffect} from 'web/hooks/use-safe-layout-effect' import {useT} from 'web/lib/locale' /** @@ -21,8 +23,19 @@ import {useT} from 'web/lib/locale' * * Deliberately kept away from the vote tally further down the page. Twelve voters next to a large * member count is the turnout tension A1 was placed on this page to manage, not to advertise. + * + * **The count-up is an argument, not decoration.** The measured numbers spin up — oversized, heavier, + * slightly blurred, settling into place — and the price does not move at all. Once the others land, the + * `$0` is the only thing left to look at, and it pulls focus by *failing* to climb: it lifts and drops + * back twice, stuck. Everything about the platform grows except what it costs. */ +const COUNT_MS = 1500 +/** Stagger between the measured stats. Small — the point is a ripple, not a queue. */ +const STAGGER_MS = 160 +/** Beat between the last number settling and the price taking over. */ +const HANDOFF_MS = 220 + function formatCount(n: number) { return n >= 10000 ? `${Math.floor(n / 1000)}k` : n.toLocaleString('en-US') } @@ -38,6 +51,52 @@ function Stat({value, label}: {value: ReactNode; label: string}) { ) } +/** + * One measured number, counting up. + * + * The type styling rides the *raw* progress rather than the eased value: the number decelerates hard so + * the last digits settle, but the scale/weight/blur have to keep moving while it does, or the type looks + * finished while the digits are still churning. + */ +function CountingStat({ + target, + label, + delay, + run, +}: { + target: number + label: string + delay: number + run: boolean +}) { + const {value, progress} = useCountUp(target, {run, duration: COUNT_MS, delay}) + const spinning = run && progress < 1 + + return ( + + {formatCount(value)} + + } + /> + ) +} + /** * A rule-bounded band rather than a row of cards. The point is to break the card rhythm, so borrowing * the card treatment would defeat it. @@ -46,29 +105,130 @@ export function StatBand() { const t = useT() const {data} = useAPIGetter('stats', {}) + // A callback ref held in state rather than `useRef`, because this component renders `null` until the + // stats arrive: with a plain ref the observer effect runs once against a node that does not exist yet + // and never re-runs when it appears, so the animation only ever played on a cache-warm client + // navigation and never on a cold page load. + const [node, setNode] = useState(null) + const [armed, setArmed] = useState(false) + const [run, setRun] = useState(false) + const [settled, setSettled] = useState(false) + + // Same shape as `Reveal`: arm before first paint, and never arm at all under reduced motion — those + // visitors get the final numbers with no animation rather than an animation we merely shortened. + useSafeLayoutEffect(() => { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + setArmed(true) + }, []) + + useEffect(() => { + if (!armed || !node || run) return + + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry.isIntersecting) return + setRun(true) + observer.disconnect() + }, + {rootMargin: '0px 0px -10% 0px', threshold: 0.01}, + ) + observer.observe(node) + return () => observer.disconnect() + }, [armed, node, run]) + + const showCountries = !!data && data.countryCount > 1 + const showConversations = !!data && data.conversations > 0 + const lastDelay = STAGGER_MS * ((showCountries ? 1 : 0) + (showConversations ? 1 : 0)) + + useEffect(() => { + if (!run) return + const id = setTimeout(() => setSettled(true), lastDelay + COUNT_MS + HANDOFF_MS) + return () => clearTimeout(id) + }, [run, lastDelay]) + if (!data?.profiles) return null + // Reduced-motion and no-JS visitors never enter the dimmed pre-settle state. + const priceLit = !armed || settled + + let delay = 0 + const next = () => (delay += STAGGER_MS) + return ( -
- - {data.countryCount > 1 && ( - + + + {showCountries && ( + )} - {data.conversations > 0 && ( - )} {/* Not a queried number, and deliberately so: it is the one claim here that is a policy rather than a measurement, and it is the whole argument of the "Completely Free" card. */} + {armed && settled && } + + {t('about.stat.price_value', '$0')} + + + } />
) } + +/** + * Kept as plain keyframes rather than Tailwind config entries: they are single-use and only meaningful + * as a sequence, and the config's animation list is already long enough to be hard to read. + */ +const styles = ` +.sb-settle{animation:sb-settle .42s cubic-bezier(.2,.9,.25,1) both} +@keyframes sb-settle{0%{transform:scale(1.07)}55%{transform:scale(.985)}100%{transform:scale(1)}} + +/* Lifts and falls back twice, then gives up at its starting position — the "stuck at zero" beat. */ +.sb-zero-stuck{animation:sb-zero-stuck 1.5s cubic-bezier(.3,.7,.3,1) both} +@keyframes sb-zero-stuck{ + 0%{transform:translateY(0) scale(1)} + 18%{transform:translateY(0) scale(1.2)} + 34%{transform:translateY(-9px) scale(1.2)} + 50%{transform:translateY(0) scale(1.2)} + 64%{transform:translateY(-4px) scale(1.15)} + 78%{transform:translateY(0) scale(1.13)} + 100%{transform:translateY(0) scale(1)} +} + +.sb-zero-glow{position:absolute;left:50%;top:50%;width:150%;padding-bottom:150%;transform:translate(-50%,-50%); + border-radius:9999px;pointer-events:none; + background:radial-gradient(circle,rgb(var(--color-primary-500)/.30),rgb(var(--color-primary-500)/0) 68%); + animation:sb-zero-glow 1.5s ease-out both} +@keyframes sb-zero-glow{0%{opacity:0}22%{opacity:1}100%{opacity:0}} + +@media (prefers-reduced-motion: reduce){ + .sb-settle,.sb-zero-stuck,.sb-zero-glow{animation:none} +} +` diff --git a/web/hooks/use-count-up.ts b/web/hooks/use-count-up.ts new file mode 100644 index 00000000..0f038977 --- /dev/null +++ b/web/hooks/use-count-up.ts @@ -0,0 +1,52 @@ +import {useRef, useState} from 'react' +import {useSafeLayoutEffect} from 'web/hooks/use-safe-layout-effect' + +/** + * Drives a number from 0 up to `target` on a rAF timeline. + * + * Returns the eased `value` and the *raw* (un-eased) `progress`, because the two are wanted for + * different things: the value should decelerate hard so the last digits settle rather than crawl, while + * the styling that rides along (scale, weight, blur) reads better on linear time — easing it twice makes + * the type snap to its final size long before the number stops moving. + * + * Callers pass `run: false` for reduced-motion or not-yet-visible, and get `progress: 1` immediately — + * the final number, no animation, no flash of zero. + */ + +// Expo-out: ~85% of the distance is covered in the first third, so it looks like a counter spinning +// down rather than a linear tween, which reads as a progress bar. +const easeOutExpo = (t: number) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)) + +export function useCountUp( + target: number, + {run, duration = 1500, delay = 0}: {run: boolean; duration?: number; delay?: number}, +) { + const [progress, setProgress] = useState(1) + const frame = useRef(null) + + // Layout effect, not `useEffect`: the zero has to be in place before the browser paints the frame in + // which `run` flips on, otherwise the final number flashes for one frame and then jumps back to 0. + useSafeLayoutEffect(() => { + if (!run) { + setProgress(1) + return + } + + setProgress(0) + let start: number | null = null + + const tick = (now: number) => { + if (start === null) start = now + const t = Math.min(1, Math.max(0, (now - start - delay) / duration)) + setProgress(t) + if (t < 1) frame.current = requestAnimationFrame(tick) + } + frame.current = requestAnimationFrame(tick) + + return () => { + if (frame.current !== null) cancelAnimationFrame(frame.current) + } + }, [run, target, duration, delay]) + + return {value: Math.round(target * easeOutExpo(progress)), progress} +} diff --git a/web/pages/about.tsx b/web/pages/about.tsx index 70ac5eb4..98ac50df 100644 --- a/web/pages/about.tsx +++ b/web/pages/about.tsx @@ -26,7 +26,6 @@ import {RepoActivity} from 'web/components/about/repo-activity' import {AlertDemo} from 'web/components/about/search-alert-demo' import {SectionLabel} from 'web/components/about/section' import {VoteEvidence} from 'web/components/about/vote-evidence' -import {GeneralButton} from 'web/components/buttons/general-button' import {PageBase} from 'web/components/page-base' import {SEO} from 'web/components/SEO' import {MemberGrowth} from 'web/components/widgets/charts' @@ -291,13 +290,18 @@ function FeaturedHelpCard({icon, title, text, buttonLabel, buttonUrl, id}: HelpC

{text}

-
- -
+ {/* Same outline treatment as the home hero's "Learn how it works" secondary CTA, so the one + outlined button on each marketing page reads as the same control: ink label on a neutral + border, with the accent held back for hover. Written out rather than routed through + `GeneralButton`, whose wrapper adds padding this card's flex row doesn't want. */} + + {buttonLabel} + ) }