11import { Stack , IconButton , Box , Tooltip , Typography , useTheme , useMediaQuery } from "@mui/material" ;
22import { useAsync } from "react-use" ;
3+ import { useState , useEffect , useRef , useCallback } from "react" ;
34import github from "../assets/wordmarks/github.svg" ;
45
5- export default function Stars ( { href, hideCountIfMobile } : { href ?: string , hideCountIfMobile ?: boolean } ) {
6+ /**
7+ * Animated star counter that counts from 0 to the actual value.
8+ * Uses requestAnimationFrame for smooth 60fps animation.
9+ * Respects prefers-reduced-motion: skips animation and shows final value instantly.
10+ */
11+ function useAnimatedCounter ( target : number | undefined , durationMs = 1600 ) : string {
12+ const [ display , setDisplay ] = useState < string > ( "" ) ;
13+ const prefersReducedMotion = useRef ( false ) ;
14+
15+ useEffect ( ( ) => {
16+ if ( typeof window !== "undefined" ) {
17+ prefersReducedMotion . current = window . matchMedia ( "(prefers-reduced-motion: reduce)" ) . matches ;
18+ }
19+ } , [ ] ) ;
20+
21+ const formatNumber = useCallback ( ( n : number ) : string => {
22+ return n . toLocaleString ( "en-US" ) ;
23+ } , [ ] ) ;
24+
25+ useEffect ( ( ) => {
26+ if ( target === undefined || target === null ) {
27+ setDisplay ( "" ) ;
28+ return ;
29+ }
30+
31+ // Respect reduced motion preference
32+ if ( prefersReducedMotion . current ) {
33+ setDisplay ( formatNumber ( target ) ) ;
34+ return ;
35+ }
36+
37+ let rafId : number ;
38+ let startTime : number | null = null ;
39+ const startValue = 0 ;
40+
41+ const animate = ( timestamp : number ) => {
42+ if ( startTime === null ) startTime = timestamp ;
43+ const elapsed = timestamp - startTime ;
44+ const progress = Math . min ( elapsed / durationMs , 1 ) ;
45+
46+ // Ease-out cubic for satisfying deceleration
47+ const eased = 1 - Math . pow ( 1 - progress , 3 ) ;
48+ const current = Math . round ( startValue + ( target - startValue ) * eased ) ;
49+
50+ setDisplay ( formatNumber ( current ) ) ;
51+
52+ if ( progress < 1 ) {
53+ rafId = requestAnimationFrame ( animate ) ;
54+ }
55+ } ;
56+
57+ rafId = requestAnimationFrame ( animate ) ;
58+
59+ return ( ) => {
60+ if ( rafId ) cancelAnimationFrame ( rafId ) ;
61+ } ;
62+ } , [ target , durationMs , formatNumber ] ) ;
63+
64+ return display ;
65+ }
66+
67+ export default function Stars ( { href, hideCountIfMobile } : { href ?: string ; hideCountIfMobile ?: boolean } ) {
668 const theme = useTheme ( ) ;
7- const isxs = useMediaQuery ( theme . breakpoints . down ( 'md' ) ) ;
69+ const isxs = useMediaQuery ( theme . breakpoints . down ( "md" ) ) ;
870
9- const { value : stars } = useAsync ( async ( ) => {
10- const response = await fetch ( ' /stars.json' ) ;
71+ const { value : stars } = useAsync ( async ( ) => {
72+ const response = await fetch ( " /stars.json" ) ;
1173 const data = await response . json ( ) ;
12- return data
13- } , [ ] )
14-
15- const display = hideCountIfMobile && isxs ? 'none' : undefined
16-
17- return < Stack spacing = { 0 } direction = 'row' alignItems = 'center' >
18- < IconButton href = { href || 'https://github.com/pkgxdev/pkgx' } >
19- < Box component = 'img' src = { github } />
20- </ IconButton >
21- < Tooltip title = 'Total Org. Stars' arrow placement = 'right' enterTouchDelay = { 0 } sx = { { display} } >
22- < Typography color = 'text.secondary' width = { 44 } fontSize = { 13 } overflow = 'clip' component = 'span' >
23- { stars }
24- </ Typography >
25- </ Tooltip >
26- </ Stack >
27- }
74+ // Handle both number and string formats
75+ const raw = typeof data === "object" && data !== null ? ( data . total ?? data . stars ?? data ) : data ;
76+ return typeof raw === "string" ? parseInt ( raw . replace ( / , / g, "" ) , 10 ) : Number ( raw ) ;
77+ } , [ ] ) ;
78+
79+ const animatedStars = useAnimatedCounter ( stars ) ;
80+ const shouldHide = hideCountIfMobile && isxs ;
81+
82+ return (
83+ < Stack spacing = { 0 } direction = "row" alignItems = "center" >
84+ < IconButton
85+ href = { href || "https://github.com/pkgxdev/pkgx" }
86+ aria-label = "View pkgx on GitHub"
87+ >
88+ < Box component = "img" src = { github } alt = "GitHub" />
89+ </ IconButton >
90+ { ! shouldHide && (
91+ < Tooltip title = "Total Org. Stars" arrow placement = "right" enterTouchDelay = { 0 } >
92+ < Typography
93+ color = "text.secondary"
94+ fontSize = { 13 }
95+ component = "span"
96+ aria-live = "polite"
97+ aria-label = { stars ? `${ stars . toLocaleString ( "en-US" ) } GitHub stars` : "Loading stars" }
98+ sx = { {
99+ minWidth : 44 ,
100+ overflow : "clip" ,
101+ fontVariantNumeric : "tabular-nums" ,
102+ fontFeatureSettings : '"tnum"' ,
103+ } }
104+ >
105+ { animatedStars || "\u00A0" }
106+ </ Typography >
107+ </ Tooltip >
108+ ) }
109+ </ Stack >
110+ ) ;
111+ }
0 commit comments