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 `<Link>` for consistent styling and hover effects.
- Enhanced visual hierarchy by applying animations and styles adapted for reduced-motion users.
This commit is contained in:
MartinBraquet
2026-07-24 21:07:39 +02:00
parent bf4c1460af
commit 6dbfab2237
3 changed files with 234 additions and 18 deletions

View File

@@ -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 (
<Stat
label={label}
value={
<span
className={clsx('inline-block origin-left', run && !spinning && 'sb-settle')}
style={
spinning
? {
transform: `scale(${1 + 0.26 * (1 - progress)})`,
fontWeight: 400 + Math.round(300 * progress),
letterSpacing: `${0.03 * (1 - progress)}em`,
filter: `blur(${1.1 * (1 - progress)}px)`,
opacity: 0.5 + 0.5 * progress,
}
: undefined
}
>
{formatCount(value)}
</span>
}
/>
)
}
/**
* 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<HTMLDivElement | null>(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 (
<div className="border-y border-canvas-200 py-8 flex flex-wrap gap-x-10 gap-y-7">
<Stat value={formatCount(data.profiles)} label={t('about.stat.members', 'Members')} />
{data.countryCount > 1 && (
<Stat
value={formatCount(data.countryCount)}
<div ref={setNode} className="border-y border-canvas-200 py-8 flex flex-wrap gap-x-10 gap-y-7">
<style>{styles}</style>
<CountingStat
target={data.profiles}
label={t('about.stat.members', 'Members')}
delay={0}
run={run}
/>
{showCountries && (
<CountingStat
target={data.countryCount}
label={t('about.stat.countries', 'Countries')}
delay={next()}
run={run}
/>
)}
{data.conversations > 0 && (
<Stat
value={formatCount(data.conversations)}
{showConversations && (
<CountingStat
target={data.conversations}
label={t('about.stat.conversations', 'Conversations')}
delay={next()}
run={run}
/>
)}
{/* 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. */}
<Stat
value={t('about.stat.price_value', '$0')}
label={t('about.stat.price', 'Cost to use')}
value={
<span className="relative inline-block">
{armed && settled && <span aria-hidden className="sb-zero-glow" />}
<span
className={clsx(
'relative inline-block origin-left transition-colors duration-700',
armed && settled && 'sb-zero-stuck',
priceLit ? 'text-ink-900' : 'text-ink-900/40',
)}
>
{t('about.stat.price_value', '$0')}
</span>
</span>
}
/>
</div>
)
}
/**
* 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}
}
`

52
web/hooks/use-count-up.ts Normal file
View File

@@ -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<number | null>(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}
}

View File

@@ -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
</h3>
<p className="text-sm text-ink-600 leading-relaxed max-w-xl">{text}</p>
</div>
<div className="shrink-0 -mb-3 -ml-3 sm:m-0">
<GeneralButton
url={buttonUrl}
content={buttonLabel}
color={'bg-transparent text-cta border-cta hover:bg-cta hover:text-white'}
/>
</div>
{/* 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. */}
<Link
href={buttonUrl}
target={buttonUrl.startsWith('http') ? '_blank' : undefined}
rel={buttonUrl.startsWith('http') ? 'noopener noreferrer' : undefined}
className="shrink-0 px-7 py-3.5 rounded-xl bg-transparent text-ink-900 font-semibold text-[15px] border-2 border-canvas-200 hover:border-primary-500 hover:text-primary-500 hover:-translate-y-0.5 transition-all duration-200 ease-out"
>
{buttonLabel}
</Link>
</div>
)
}