1+ // Counter animations: uses Odometer library for odometer mode; keeps other modes intact.
2+ ( function ( ) {
3+ // Check if user prefers reduced motion
4+ const prefersReducedMotion = ( ) => {
5+ return window . matchMedia && window . matchMedia ( '(prefers-reduced-motion: reduce)' ) . matches ;
6+ } ;
7+
8+ // Smooth easing function for polished marketing counters
9+ const easeOutCubic = ( t ) => 1 - Math . pow ( 1 - t , 3 ) ;
10+
11+ // Generate random number similar to target
12+ const randomLike = ( target , decimals ) => {
13+ const pow = Math . pow ( 10 , decimals ) ;
14+ const max = Math . max ( 1 , Math . abs ( target ) ) ;
15+ const n = Math . random ( ) * max ;
16+ return Math . round ( n * pow ) / pow ;
17+ } ;
18+
19+ // Parse duration from various formats: "2s", "2.5s", "2500", "2500ms"
20+ const parseDuration = ( raw , fallback = 1600 ) => {
21+ if ( raw == null || raw === '' ) return fallback ;
22+
23+ const s = String ( raw ) . trim ( ) . toLowerCase ( ) ;
24+ const sec = s . endsWith ( 's' ) && ! s . endsWith ( 'ms' ) ;
25+ const ms = s . endsWith ( 'ms' ) ;
26+ const n = parseFloat ( s . replace ( / ( m s | s ) $ / , '' ) ) ;
27+
28+ if ( isNaN ( n ) ) return fallback ;
29+
30+ let val = sec ? n * 1000 : n ;
31+ // Treat small integers as seconds
32+ if ( ! sec && ! ms && val < 50 ) val *= 1000 ;
33+
34+ return Math . max ( 0 , Math . round ( val ) ) ;
35+ } ;
36+
37+ // Build Odometer format string based on decimal places
38+ const odometerFormat = ( decimals ) => {
39+ return decimals > 0 ? `(,ddd).${ 'd' . repeat ( decimals ) } ` : '(,ddd)' ;
40+ } ;
41+
42+ // Initialize or reuse Odometer instance
43+ const ensureOdometer = ( el , decimals , durationMs ) => {
44+ const Odo = window . Odometer ;
45+ if ( ! Odo ) return null ;
46+
47+ const desiredFormat = odometerFormat ( decimals ) ;
48+ const existing = el . __odometer ;
49+
50+ // Check if existing instance needs update
51+ if ( existing ) {
52+ let changed = false ;
53+
54+ if ( existing . options ) {
55+ if ( existing . options . duration !== durationMs ) {
56+ existing . options . duration = durationMs ;
57+ changed = true ;
58+ }
59+ if ( existing . options . format !== desiredFormat ) {
60+ existing . options . format = desiredFormat ;
61+ changed = true ;
62+ }
63+ } else {
64+ changed = true ;
65+ }
66+
67+ if ( ! changed ) return existing ;
68+
69+ // Clean up existing instance
70+ try {
71+ el . __odometer = null ;
72+ el . classList . remove ( 'odometer' ) ;
73+ } catch ( _ ) { }
74+ }
75+
76+ // Create new Odometer instance
77+ const odo = new Odo ( {
78+ el,
79+ value : 0 ,
80+ duration : Number . isFinite ( durationMs ) ? Number ( durationMs ) : 1600 ,
81+ format : desiredFormat ,
82+ } ) ;
83+
84+ el . __odometer = odo ;
85+ return odo ;
86+ } ;
87+
88+ // Main function to run counter animations
89+ const run = ( ) => {
90+ const els = document . querySelectorAll ( '.has-number-counter, number-counter.has-number-counter' ) ;
91+ if ( ! els . length ) return ;
92+
93+ // Check for reduced motion preference
94+ const reducedMotion = prefersReducedMotion ( ) ;
95+
96+ // Animate individual counter element
97+ const animate = ( el ) => {
98+ const target = parseFloat ( el . dataset . countTo ?? el . textContent ) ;
99+ if ( isNaN ( target ) ) return ;
100+
101+ const rawDuration = el . dataset . duration ?? '1600' ;
102+ const duration = parseDuration ( rawDuration , 1600 ) ;
103+
104+ // Determine animation mode
105+ const requested = String ( el . dataset . anim ?? '' ) . trim ( ) . toLowerCase ( ) ;
106+ const forceOdo = el . hasAttribute ( 'data-odometer' ) || requested === 'odometer' ;
107+ const mode = forceOdo ? 'odometer' : ( requested || 'ease' ) ;
108+
109+ const decimals = ( String ( target ) . split ( '.' ) [ 1 ] || '' ) . length ;
110+
111+ // If user prefers reduced motion and element respects it, show final value immediately
112+ if ( reducedMotion ) {
113+ // Display final value without animation
114+ const formatted = Number ( target ) . toLocaleString ( undefined , {
115+ minimumFractionDigits : decimals ,
116+ maximumFractionDigits : decimals ,
117+ } ) ;
118+ el . textContent = formatted ;
119+ el . setAttribute ( 'data-final' , formatted ) ;
120+
121+ // Add a class to indicate animation was skipped for styling purposes
122+ el . classList . add ( 'animation-skipped' ) ;
123+ return ;
124+ }
125+
126+ // Format number with locale-specific formatting
127+ const renderNumber = ( val ) => {
128+ el . textContent = Number ( val ) . toLocaleString ( undefined , {
129+ minimumFractionDigits : decimals ,
130+ maximumFractionDigits : decimals ,
131+ } ) ;
132+ } ;
133+
134+ // Cleanup if not using odometer mode
135+ if ( mode !== 'odometer' ) {
136+ if ( el . __odometer ) {
137+ try { el . __odometer = null ; } catch ( e ) { }
138+ }
139+ if ( el . classList . contains ( 'odometer' ) ) {
140+ el . classList . remove ( 'odometer' ) ;
141+ }
142+ }
143+
144+ // ODOMETER MODE
145+ if ( mode === 'odometer' ) {
146+ const odo = ensureOdometer ( el , decimals , duration ) ;
147+
148+ // Fallback to ease animation if library missing
149+ if ( ! odo ) {
150+ let startTime = null ;
151+ const step = ( ts ) => {
152+ if ( ! startTime ) startTime = ts ;
153+ const raw = Math . min ( ( ts - startTime ) / duration , 1 ) ;
154+ const p = easeOutCubic ( raw ) ;
155+ const value = target * p ;
156+
157+ renderNumber ( value ) ;
158+
159+ if ( raw < 1 ) {
160+ requestAnimationFrame ( step ) ;
161+ } else {
162+ renderNumber ( target ) ;
163+ const formatted = Number ( target ) . toLocaleString ( undefined , {
164+ minimumFractionDigits : decimals ,
165+ maximumFractionDigits : decimals ,
166+ } ) ;
167+ el . setAttribute ( 'data-final' , formatted ) ;
168+ }
169+ } ;
170+ renderNumber ( 0 ) ;
171+ requestAnimationFrame ( step ) ;
172+ return ;
173+ }
174+
175+ // Initialize with zero and trigger animation
176+ renderNumber ( 0 ) ;
177+
178+ requestAnimationFrame ( ( ) => {
179+ // Ensure duration is applied
180+ if ( odo . options && odo . options . duration !== duration ) {
181+ odo . options . duration = duration ;
182+ }
183+ odo . update ( target ) ;
184+ const seconds = Math . max ( 0 , Math . round ( duration ) ) / 1000 ;
185+ el . style . setProperty ( '--odo-duration' , `${ seconds } s` ) ;
186+ } ) ;
187+
188+ // Set final attribute when animation completes
189+ const setFinal = ( ) => {
190+ const formatted = Number ( target ) . toLocaleString ( undefined , {
191+ minimumFractionDigits : decimals ,
192+ maximumFractionDigits : decimals ,
193+ } ) ;
194+ el . setAttribute ( 'data-final' , formatted ) ;
195+ } ;
196+
197+ // Handle completion with event listener and timeout fallback
198+ let done = false ;
199+ const onDone = ( ) => {
200+ if ( done ) return ;
201+ done = true ;
202+ setFinal ( ) ;
203+ el . removeEventListener ( 'odometerdone' , onDone ) ;
204+ } ;
205+
206+ el . addEventListener ( 'odometerdone' , onDone , { once : true } ) ;
207+ setTimeout ( onDone , duration + 120 ) ;
208+ return ;
209+ }
210+
211+ // NON-ODOMETER MODES (ease/linear/steps/scramble)
212+ let startTime = null ;
213+
214+ const step = ( ts ) => {
215+ if ( ! startTime ) startTime = ts ;
216+ const raw = Math . min ( ( ts - startTime ) / duration , 1 ) ;
217+
218+ let p = raw ;
219+
220+ let value ;
221+ switch ( mode ) {
222+ case 'steps' : {
223+ const steps = 20 ;
224+ const snapped = Math . ceil ( p * steps ) / steps ;
225+ value = target * snapped ;
226+ break ;
227+ }
228+ case 'scramble' : {
229+ value = raw < 0.8
230+ ? randomLike ( target , decimals )
231+ : target * easeOutCubic ( ( raw - 0.8 ) / 0.2 ) ;
232+ break ;
233+ }
234+ case 'linear' :
235+ default :
236+ value = target * p ;
237+ break ;
238+ }
239+
240+ renderNumber ( value ) ;
241+
242+ if ( raw < 1 ) {
243+ requestAnimationFrame ( step ) ;
244+ } else {
245+ renderNumber ( target ) ;
246+ const formatted = Number ( target ) . toLocaleString ( undefined , {
247+ minimumFractionDigits : decimals ,
248+ maximumFractionDigits : decimals ,
249+ } ) ;
250+ el . setAttribute ( 'data-final' , formatted ) ;
251+ }
252+ } ;
253+
254+ // Initialize starting value for non-odometer modes
255+ el . textContent = '' ;
256+ el . append (
257+ document . createTextNode (
258+ ( 0 ) . toLocaleString ( undefined , {
259+ minimumFractionDigits : decimals ,
260+ maximumFractionDigits : decimals ,
261+ } )
262+ )
263+ ) ;
264+ requestAnimationFrame ( step ) ;
265+ } ;
266+
267+ // Intersection Observer for scroll-triggered animations
268+ const io = new IntersectionObserver (
269+ ( entries , obs ) => {
270+ entries . forEach ( ( entry ) => {
271+ if ( ! entry . isIntersecting ) return ;
272+ animate ( entry . target ) ;
273+ obs . unobserve ( entry . target ) ;
274+ } ) ;
275+ } ,
276+ { threshold : 0.2 }
277+ ) ;
278+
279+ // Process each counter element
280+ els . forEach ( ( el ) => {
281+ // Check if animation should start on scroll (default: true)
282+ const onScrollAttr = ( el . dataset . onScroll ?? 'true' ) . toString ( ) . toLowerCase ( ) ;
283+ const onScroll = onScrollAttr !== 'false' ;
284+
285+ if ( onScroll ) {
286+ io . observe ( el ) ; // Start when scrolled into view
287+ } else {
288+ animate ( el ) ; // Start immediately
289+ }
290+ } ) ;
291+ } ;
292+
293+ // Initialize after fonts are ready to prevent metric jumps
294+ const start = ( ) => {
295+ if ( document . readyState === 'loading' ) {
296+ document . addEventListener ( 'DOMContentLoaded' , run ) ;
297+ } else {
298+ run ( ) ;
299+ }
300+ } ;
301+
302+ // Wait for fonts to load before starting
303+ if ( document . fonts && 'ready' in document . fonts ) {
304+ document . fonts . ready . then ( start ) ;
305+ } else {
306+ start ( ) ;
307+ }
308+ } ) ( ) ;
0 commit comments