mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
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:
@@ -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
52
web/hooks/use-count-up.ts
Normal 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}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user