Skip to content

Commit 10254fd

Browse files
committed
feat: add counter format with animations and Odometer integration
1 parent 7d9ca1e commit 10254fd

File tree

9 files changed

+694
-0
lines changed

9 files changed

+694
-0
lines changed

assets/counter.js

Lines changed: 308 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,308 @@
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(/(ms|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+
})();

blablablocks-formats.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ function blablablocks_register_assets()
9494
[],
9595
$version
9696
);
97+
98+
// Register Odometer library for counter animation.
99+
wp_register_script(
100+
'odometer-lib',
101+
'https://cdn.jsdelivr.net/npm/odometer@0.4.8/odometer.min.js',
102+
[],
103+
'0.4.8',
104+
true
105+
);
97106
}
98107
add_action('init', 'blablablocks_register_assets');
99108

@@ -112,3 +121,20 @@ function blablablocks_formats_enqueue_assets()
112121
}
113122
}
114123
add_action('enqueue_block_assets', 'blablablocks_formats_enqueue_assets');
124+
125+
/**
126+
* Enqueue counter animation script.
127+
*
128+
* This script is used for the counter format to animate number changes.
129+
*/
130+
function blablablocks_formats_enqueue_counter_animation()
131+
{
132+
wp_enqueue_script(
133+
'blablablocks-counter-frontend',
134+
plugins_url('assets/counter.js', __FILE__),
135+
['odometer-lib'],
136+
'1.0.0',
137+
true
138+
);
139+
}
140+
add_action('wp_enqueue_scripts', 'blablablocks_formats_enqueue_counter_animation');

0 commit comments

Comments
 (0)