PageSourceSearch

https://www.justborn.com/wp-content/themes/studioswift/blocks/animated-hero/script.js

js justborn.com collected 2026-10-02 08:00:56 UTC 8,615 bytes, 309 lines download raw bytes

1/**
2 * Animated Hero Block - JavaScript
3 *
4 * State-driven hero animations. CSS owns all motion; JS only toggles
5 * classes, shuffles candy reveal order, and runs desktop parallax.
6 *
7 * @package StudioSwift
8 */
9
10(function () {
11	'use strict';
12
13	const DESKTOP_BREAKPOINT = '(min-width: 1199px)';
14	const TABLET_BREAKPOINT = '(min-width: 768px) and (max-width: 1198px)';
15	const LERP_FACTOR = 0.08;
16	const PARALLAX_RANGE = 20;
17	// Matches CSS entrance end: candy base delay + last stagger + pop settle (+ buffer).
18	const ENTRANCE_COMPLETE_MS = 2600;
19
20	/**
21	 * Fisher-Yates shuffle (in-place).
22	 *
23	 * @param {Array} array
24	 * @return {Array}
25	 */
26	function fisherYatesShuffle(array) {
27		for (let i = array.length - 1; i > 0; i -= 1) {
28			const j = Math.floor(Math.random() * (i + 1));
29			[array[i], array[j]] = [array[j], array[i]];
30		}
31
32		return array;
33	}
34
35	/**
36	 * @param {HTMLElement} hero
37	 */
38	function AnimatedHero(hero) {
39		this.hero = hero;
40		this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
41		this.desktopMedia = window.matchMedia(DESKTOP_BREAKPOINT);
42		this.tabletMedia = window.matchMedia(TABLET_BREAKPOINT);
43
44		this.candyGroups = [];
45		this.parallaxLayers = [];
46		this.products = [];
47
48		this.targetX = 0;
49		this.targetY = 0;
50		this.currentX = 0;
51		this.currentY = 0;
52		this.rafId = 0;
53		this.isParallaxActive = false;
54		this.completeTimer = 0;
55
56		this.onMouseMove = this.onMouseMove.bind(this);
57		this.onMediaChange = this.onMediaChange.bind(this);
58		this.onReducedMotionChange = this.onReducedMotionChange.bind(this);
59		this.updateParallax = this.updateParallax.bind(this);
60	}
61
62	AnimatedHero.prototype.cacheDom = function cacheDom() {
63		this.candyGroups = Array.from(
64			this.hero.querySelectorAll('.animated-hero__candy-group')
65		);
66		this.parallaxLayers = Array.from(
67			this.hero.querySelectorAll('.js-animated-hero-parallax')
68		);
69		this.products = Array.from(
70			this.hero.querySelectorAll('.js-animated-hero-product')
71		);
72	};
73
74	AnimatedHero.prototype.revealCandies = function revealCandies() {
75		const groupIndexes = [0, 1, 2, 3];
76		const revealOrder = fisherYatesShuffle(groupIndexes.slice());
77
78		revealOrder.forEach((groupIndex, revealIndex) => {
79			const selector = `.animated-hero__candy-group[data-group="${groupIndex + 1}"]`;
80			const groups = this.hero.querySelectorAll(selector);
81
82			groups.forEach((group) => {
83				group.classList.remove(
84					'animated-hero__candy-group--reveal-0',
85					'animated-hero__candy-group--reveal-1',
86					'animated-hero__candy-group--reveal-2',
87					'animated-hero__candy-group--reveal-3'
88				);
89				group.classList.add(`animated-hero__candy-group--reveal-${revealIndex}`);
90			});
91		});
92	};
93
94	AnimatedHero.prototype.revealProducts = function revealProducts() {
95		this.hero.classList.add('animated-hero--loaded');
96	};
97
98	/**
99	 * Freeze entrance animations in their final state so breakpoint
100	 * media-query swaps (768 / 992 / etc.) cannot replay them.
101	 */
102	AnimatedHero.prototype.markEntranceComplete = function markEntranceComplete() {
103		this.hero.classList.add('animated-hero--complete');
104	};
105
106	AnimatedHero.prototype.scheduleEntranceComplete = function scheduleEntranceComplete(delayMs) {
107		if (this.completeTimer) {
108			window.clearTimeout(this.completeTimer);
109			this.completeTimer = 0;
110		}
111
112		if (delayMs <= 0) {
113			this.markEntranceComplete();
114			return;
115		}
116
117		this.completeTimer = window.setTimeout(() => {
118			this.completeTimer = 0;
119			this.markEntranceComplete();
120		}, delayMs);
121	};
122
123	AnimatedHero.prototype.onMouseMove = function onMouseMove(event) {
124		const rect = this.hero.getBoundingClientRect();
125		const relativeX = (event.clientX - rect.left) / rect.width - 0.5;
126		const relativeY = (event.clientY - rect.top) / rect.height - 0.5;
127
128		this.targetX = relativeX * PARALLAX_RANGE;
129		this.targetY = relativeY * PARALLAX_RANGE;
130	};
131
132	AnimatedHero.prototype.updateParallax = function updateParallax() {
133		this.rafId = 0;
134
135		this.currentX += (this.targetX - this.currentX) * LERP_FACTOR;
136		this.currentY += (this.targetY - this.currentY) * LERP_FACTOR;
137
138		this.products.forEach((product, index) => {
139			const layer = this.parallaxLayers[index];
140
141			if (!layer) {
142				return;
143			}
144
145			const multiplier = parseFloat(product.dataset.parallaxMultiplier || '0.45');
146			const x = this.currentX * multiplier;
147			const y = this.currentY * multiplier;
148
149			layer.style.setProperty('--parallax-x', `${x.toFixed(2)}px`);
150			layer.style.setProperty('--parallax-y', `${y.toFixed(2)}px`);
151		});
152
153		if (this.isParallaxActive) {
154			this.rafId = window.requestAnimationFrame(this.updateParallax);
155		}
156	};
157
158	AnimatedHero.prototype.startParallaxLoop = function startParallaxLoop() {
159		if (this.rafId) {
160			return;
161		}
162
163		this.rafId = window.requestAnimationFrame(this.updateParallax);
164	};
165
166	AnimatedHero.prototype.setupMouseTracking = function setupMouseTracking() {
167		if (this.reducedMotion || !this.desktopMedia.matches) {
168			return;
169		}
170
171		this.isParallaxActive = true;
172		this.hero.addEventListener('mousemove', this.onMouseMove, { passive: true });
173		this.startParallaxLoop();
174	};
175
176	AnimatedHero.prototype.teardownMouseTracking = function teardownMouseTracking() {
177		this.isParallaxActive = false;
178		this.hero.removeEventListener('mousemove', this.onMouseMove);
179
180		if (this.rafId) {
181			window.cancelAnimationFrame(this.rafId);
182			this.rafId = 0;
183		}
184
185		this.targetX = 0;
186		this.targetY = 0;
187		this.currentX = 0;
188		this.currentY = 0;
189
190		this.parallaxLayers.forEach((layer) => {
191			layer.style.setProperty('--parallax-x', '0px');
192			layer.style.setProperty('--parallax-y', '0px');
193		});
194	};
195
196	AnimatedHero.prototype.onMediaChange = function onMediaChange() {
197		this.teardownMouseTracking();
198
199		if (this.desktopMedia.matches && !this.reducedMotion) {
200			this.setupMouseTracking();
201		}
202	};
203
204	AnimatedHero.prototype.onReducedMotionChange = function onReducedMotionChange() {
205		this.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
206
207		if (this.reducedMotion) {
208			this.teardownMouseTracking();
209			this.hero.classList.add('animated-hero--loaded');
210			this.scheduleEntranceComplete(0);
211			return;
212		}
213
214		this.onMediaChange();
215	};
216
217	AnimatedHero.prototype.bindEvents = function bindEvents() {
218		this.desktopMedia.addEventListener('change', this.onMediaChange);
219		this.tabletMedia.addEventListener('change', this.onMediaChange);
220
221		const reducedMotionMedia = window.matchMedia('(prefers-reduced-motion: reduce)');
222		reducedMotionMedia.addEventListener('change', this.onReducedMotionChange);
223		this.reducedMotionMedia = reducedMotionMedia;
224	};
225
226	AnimatedHero.prototype.init = function init() {
227		this.cacheDom();
228		this.bindEvents();
229		this.revealCandies();
230		this.revealProducts();
231
232		if (this.reducedMotion) {
233			this.scheduleEntranceComplete(0);
234			return;
235		}
236
237		this.scheduleEntranceComplete(ENTRANCE_COMPLETE_MS);
238		this.setupMouseTracking();
239	};
240
241	AnimatedHero.prototype.destroy = function destroy() {
242		this.teardownMouseTracking();
243
244		if (this.completeTimer) {
245			window.clearTimeout(this.completeTimer);
246			this.completeTimer = 0;
247		}
248
249		if (this.desktopMedia) {
250			this.desktopMedia.removeEventListener('change', this.onMediaChange);
251		}
252
253		if (this.tabletMedia) {
254			this.tabletMedia.removeEventListener('change', this.onMediaChange);
255		}
256
257		if (this.reducedMotionMedia) {
258			this.reducedMotionMedia.removeEventListener('change', this.onReducedMotionChange);
259		}
260
261		this.hero.classList.remove('animated-hero--loaded', 'animated-hero--complete');
262
263		this.candyGroups.forEach((group) => {
264			group.classList.remove(
265				'animated-hero__candy-group--reveal-0',
266				'animated-hero__candy-group--reveal-1',
267				'animated-hero__candy-group--reveal-2',
268				'animated-hero__candy-group--reveal-3'
269			);
270		});
271
272		delete this.hero._animatedHeroInstance;
273		delete this.hero.dataset.animatedHeroInit;
274	};
275
276	/**
277	 * Initialize a single hero instance.
278	 *
279	 * @param {HTMLElement} hero
280	 */
281	function initAnimatedHeroInstance(hero) {
282		if (!hero) {
283			return;
284		}
285
286		if (hero.dataset.animatedHeroInit === 'true' && hero._animatedHeroInstance) {
287			hero._animatedHeroInstance.destroy();
288		}
289
290		const instance = new AnimatedHero(hero);
291		hero.dataset.animatedHeroInit = 'true';
292		hero._animatedHeroInstance = instance;
293		instance.init();
294	}
295
296	function initAnimatedHero() {
297		document.querySelectorAll('.js-animated-hero').forEach(initAnimatedHeroInstance);
298	}
299
300	if (document.readyState === 'loading') {
301		document.addEventListener('DOMContentLoaded', initAnimatedHero);
302	} else {
303		initAnimatedHero();
304	}
305
306	if (window.acf) {
307		window.acf.addAction('render_block_preview/type=animated-hero', initAnimatedHero);
308	}
309})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.