PageSourceSearch

https://www.remodely.ai/js/effects.js?v=5

js remodely.ai collected 2026-10-05 14:09:39 UTC 15,132 bytes, 411 lines download raw bytes

1/**
2 * Remodely AI — full-body UX effects layer
3 * Parallax stardust, scroll-reveal, mouse-tilt iso, scroll progress.
4 *
5 * All effects respect prefers-reduced-motion. Each effect feature-detects
6 * and is fully optional — if any module fails, the others keep running.
7 */
8(() => {
9  'use strict';
10
11  const prefersReducedMotion = window.matchMedia(
12    '(prefers-reduced-motion: reduce)'
13  ).matches;
14
15  /* ---------------------------------------------------------------
16     1. SCROLL PROGRESS BAR (top of viewport, orange, 2px)
17     --------------------------------------------------------------- */
18  function initScrollProgress() {
19    const bar = document.createElement('div');
20    bar.id = 'rmd-scroll-progress';
21    document.body.appendChild(bar);
22
23    let ticking = false;
24    const update = () => {
25      const h = document.documentElement;
26      const scrolled = h.scrollTop;
27      const max = h.scrollHeight - h.clientHeight;
28      const pct = max > 0 ? (scrolled / max) * 100 : 0;
29      bar.style.transform = `scaleX(${pct / 100})`;
30      ticking = false;
31    };
32
33    window.addEventListener(
34      'scroll',
35      () => {
36        if (!ticking) {
37          requestAnimationFrame(update);
38          ticking = true;
39        }
40      },
41      { passive: true }
42    );
43  }
44
45  /* ---------------------------------------------------------------
46     2. PARALLAX STARFIELD (3 layers — deep / mid / near)
47     Three independent star layers drift at different rates so
48     scrolling reveals real depth. Deep barely moves (far away),
49     mid medium, near fastest (foreground).
50     --------------------------------------------------------------- */
51  function initParallaxStars() {
52    if (prefersReducedMotion) return;
53    // Mobile: skip parallax entirely. iOS Safari repaints fixed-position
54    // background-image layers poorly, and updating bg-position on every
55    // scroll frame for two full-viewport divs causes scroll jank on
56    // phones. The starfield divs are also display:none on <=768px via
57    // CSS, so this is just belt-and-suspenders.
58    if (window.matchMedia('(max-width: 768px)').matches) return;
59
60    const layerMid  = document.querySelector('.starfield-mid');
61    const layerNear = document.querySelector('.starfield-near');
62
63    // Drive the deep layer (body::before) via a CSS variable so we
64    // don't have to manipulate the pseudo-element directly.
65    const deepStyle = document.createElement('style');
66    deepStyle.textContent = 'body::before{background-position:0 var(--deep-y,0px)!important;}';
67    document.head.appendChild(deepStyle);
68
69    let ticking = false;
70    const update = () => {
71      const y = window.scrollY;
72      document.body.style.setProperty('--deep-y', `${(-y * 0.10).toFixed(1)}px`);
73      if (layerMid)  layerMid.style.backgroundPositionY  = `${(-y * 0.30).toFixed(1)}px`;
74      if (layerNear) layerNear.style.backgroundPositionY = `${(-y * 0.55).toFixed(1)}px`;
75      ticking = false;
76    };
77
78    window.addEventListener(
79      'scroll',
80      () => {
81        if (!ticking) {
82          requestAnimationFrame(update);
83          ticking = true;
84        }
85      },
86      { passive: true }
87    );
88
89    update();
90  }
91
92  /* ---------------------------------------------------------------
93     3. SCROLL-REVEAL — fade-up sections / cards as they enter view
94     --------------------------------------------------------------- */
95  function initScrollReveal() {
96    if (prefersReducedMotion) return;
97    if (!('IntersectionObserver' in window)) return;
98
99    // Tall content blocks (.pillar, .terminal-block) need an extra-low
100     // threshold or they never fire on long pages — leaving them
101     // permanently invisible. Use a tiny threshold and a fallback timer.
102    const targets = document.querySelectorAll(
103      '.paths__card, .journey__step, .dev-card, ' +
104      '.case-study-stat, .case-study-body, ' +
105      '.section-header, .why-card, .live-tool, .aria-stat'
106    );
107
108    targets.forEach((el) => el.classList.add('rmd-reveal'));
109
110    // Failsafe: after 3 seconds, force-reveal anything still hidden so
111    // a missed observer call can never leave content invisible.
112    setTimeout(() => {
113      document.querySelectorAll('.rmd-reveal:not(.rmd-reveal--visible)').forEach((el) => {
114        el.classList.add('rmd-reveal--visible');
115      });
116    }, 3000);
117
118    const observer = new IntersectionObserver(
119      (entries) => {
120        entries.forEach((entry, i) => {
121          if (entry.isIntersecting) {
122            // Stagger by a small delay among siblings entering together
123            const sib = Array.from(entry.target.parentElement?.children || []).indexOf(
124              entry.target
125            );
126            entry.target.style.transitionDelay = `${Math.min(sib, 6) * 60}ms`;
127            entry.target.classList.add('rmd-reveal--visible');
128            observer.unobserve(entry.target);
129          }
130        });
131      },
132      { threshold: 0.12, rootMargin: '0px 0px -40px 0px' }
133    );
134
135    targets.forEach((el) => observer.observe(el));
136  }
137
138  /* ---------------------------------------------------------------
139     4. MOUSE-TILT on the hero iso illustration
140     --------------------------------------------------------------- */
141  function initHeroTilt() {
142    if (prefersReducedMotion) return;
143    const iso = document.querySelector('.hero-iso');
144    if (!iso) return;
145
146    const img = iso.querySelector('img');
147    if (!img) return;
148
149    const heroSection = document.querySelector('.hero');
150    if (!heroSection) return;
151
152    let raf = null;
153    let targetX = 0,
154      targetY = 0,
155      currentX = 0,
156      currentY = 0;
157
158    const animate = () => {
159      currentX += (targetX - currentX) * 0.08;
160      currentY += (targetY - currentY) * 0.08;
161      img.style.transform = `perspective(1200px) rotateY(${currentX}deg) rotateX(${currentY}deg)`;
162
163      if (Math.abs(targetX - currentX) > 0.05 || Math.abs(targetY - currentY) > 0.05) {
164        raf = requestAnimationFrame(animate);
165      } else {
166        raf = null;
167      }
168    };
169
170    heroSection.addEventListener('mousemove', (e) => {
171      const r = heroSection.getBoundingClientRect();
172      const x = (e.clientX - r.left) / r.width;
173      const y = (e.clientY - r.top) / r.height;
174      targetX = (x - 0.5) * 6; // up to ±3deg
175      targetY = (0.5 - y) * 5;
176      if (!raf) raf = requestAnimationFrame(animate);
177    });
178
179    heroSection.addEventListener('mouseleave', () => {
180      targetX = 0;
181      targetY = 0;
182      if (!raf) raf = requestAnimationFrame(animate);
183    });
184  }
185
186  /* ---------------------------------------------------------------
187     5. DOM ready boot
188     --------------------------------------------------------------- */
189  const boot = () => {
190    try { initScrollProgress(); } catch (e) { console.warn('[fx] progress', e); }
191    try { initParallaxStars(); } catch (e) { console.warn('[fx] parallax', e); }
192    try { initScrollReveal(); } catch (e) { console.warn('[fx] reveal', e); }
193    try { initHeroTilt(); } catch (e) { console.warn('[fx] tilt', e); }
194  };
195
196  if (document.readyState === 'loading') {
197    document.addEventListener('DOMContentLoaded', boot);
198  } else {
199    boot();
200  }
201})();
202
203/* ===================================================================
204 * EXTENDED EFFECTS LAYER (2026-04-29 wave 2)
205 * Cursor glow, count-up stats, magnetic CTAs, layered iso parallax.
206 * Each module is independent — failures don't cascade.
207 * =================================================================== */
208(() => {
209  'use strict';
210
211  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
212  const fineCursor = window.matchMedia('(pointer: fine)').matches;
213
214  /* -----------------------------------------------------------------
215     6. CURSOR GLOW — soft orange spotlight follows the mouse on dark
216     sections only. Auto-hides on touch devices. Throttled with rAF.
217     ----------------------------------------------------------------- */
218  function initCursorGlow() {
219    if (reduced || !fineCursor) return;
220
221    const glow = document.createElement('div');
222    glow.id = 'rmd-cursor-glow';
223    glow.setAttribute('aria-hidden', 'true');
224    document.body.appendChild(glow);
225
226    let tx = -200, ty = -200, cx = -200, cy = -200, raf = null, hidden = true;
227
228    const animate = () => {
229      cx += (tx - cx) * 0.18;
230      cy += (ty - cy) * 0.18;
231      glow.style.transform = `translate(${cx}px, ${cy}px) translate(-50%, -50%)`;
232      if (Math.abs(tx - cx) > 0.5 || Math.abs(ty - cy) > 0.5) {
233        raf = requestAnimationFrame(animate);
234      } else {
235        raf = null;
236      }
237    };
238
239    document.addEventListener('mousemove', (e) => {
240      tx = e.clientX;
241      ty = e.clientY;
242      if (hidden) {
243        hidden = false;
244        glow.classList.add('rmd-cursor-glow--visible');
245      }
246      if (!raf) raf = requestAnimationFrame(animate);
247    }, { passive: true });
248
249    document.addEventListener('mouseleave', () => {
250      hidden = true;
251      glow.classList.remove('rmd-cursor-glow--visible');
252    });
253  }
254
255  /* -----------------------------------------------------------------
256     7. COUNT-UP — animates numeric stats as they enter viewport
257     Targets: any element with [data-count-to] OR matching common
258     stat number selectors. Stops at the actual final value parsed
259     from the textContent so prefix/suffix is preserved.
260     ----------------------------------------------------------------- */
261  function initCountUp() {
262    if (reduced) return;
263    if (!('IntersectionObserver' in window)) return;
264
265    // Find numeric tokens in case-study stats and why-section
266    const candidates = document.querySelectorAll(
267      '.case-study-stat__num, .why-number, .product-stat-number'
268    );
269
270    const animate = (el) => {
271      const original = el.textContent.trim();
272      // Extract first number-like token (handles "$48k+", "400+", "10+")
273      const match = original.match(/(\d+(?:[\.,]\d+)?)/);
274      if (!match) return;
275      const finalNum = parseFloat(match[1].replace(/,/g, ''));
276      if (!isFinite(finalNum) || finalNum === 0) return;
277
278      const prefix = original.slice(0, match.index);
279      const suffix = original.slice(match.index + match[0].length);
280      const duration = 1100;
281      const start = performance.now();
282
283      const tick = (now) => {
284        const t = Math.min((now - start) / duration, 1);
285        const eased = 1 - Math.pow(1 - t, 3);
286        const v = finalNum * eased;
287        // Format like the original (preserve decimals if present)
288        const decimals = (match[1].split('.')[1] || '').length;
289        const formatted = decimals
290          ? v.toFixed(decimals)
291          : Math.round(v).toLocaleString();
292        el.textContent = `${prefix}${formatted}${suffix}`;
293        if (t < 1) requestAnimationFrame(tick);
294        else el.textContent = original;
295      };
296
297      el.textContent = `${prefix}0${suffix}`;
298      requestAnimationFrame(tick);
299    };
300
301    const obs = new IntersectionObserver((entries) => {
302      entries.forEach((e) => {
303        if (e.isIntersecting) {
304          animate(e.target);
305          obs.unobserve(e.target);
306        }
307      });
308    }, { threshold: 0.6 });
309
310    candidates.forEach((el) => obs.observe(el));
311  }
312
313  /* -----------------------------------------------------------------
314     8. MAGNETIC CTAs — buttons subtly attract the cursor when near.
315     Adds a "this site feels alive" touch without being gimmicky.
316     ----------------------------------------------------------------- */
317  function initMagnetic() {
318    if (reduced || !fineCursor) return;
319
320    const buttons = document.querySelectorAll(
321      '.hero-grader-inline__row button, .paths__cta, .lead-form-submit, .aria-cta, .case-study-cta, .aria-hero-btn'
322    );
323
324    buttons.forEach((btn) => {
325      const radius = 80;
326      btn.style.transition = 'transform 0.3s cubic-bezier(0.2, 1, 0.3, 1)';
327
328      btn.addEventListener('mousemove', (e) => {
329        const r = btn.getBoundingClientRect();
330        const cx = r.left + r.width / 2;
331        const cy = r.top + r.height / 2;
332        const dx = e.clientX - cx;
333        const dy = e.clientY - cy;
334        const dist = Math.hypot(dx, dy);
335        if (dist < radius) {
336          const power = 1 - dist / radius;
337          btn.style.transform = `translate(${dx * 0.18 * power}px, ${dy * 0.18 * power}px)`;
338        }
339      });
340
341      btn.addEventListener('mouseleave', () => {
342        btn.style.transform = '';
343      });
344    });
345  }
346
347  /* -----------------------------------------------------------------
348     9. ISO LAYER PARALLAX — the floating cards inside the hero iso
349     SVG each get their own subtle parallax offset on mousemove.
350     The SVG <g> groups have transforms applied; we wrap the image
351     so we can layer adjustments on top via CSS variables.
352     ----------------------------------------------------------------- */
353  function initIsoParallax() {
354    if (reduced || !fineCursor) return;
355    const heroSection = document.querySelector('.hero');
356    const iso = document.querySelector('.hero-iso');
357    if (!heroSection || !iso) return;
358
359    heroSection.addEventListener('mousemove', (e) => {
360      const r = heroSection.getBoundingClientRect();
361      const x = (e.clientX - r.left) / r.width - 0.5;
362      const y = (e.clientY - r.top) / r.height - 0.5;
363      // Set CSS variables; the iso container already has perspective
364      iso.style.setProperty('--iso-x', x.toFixed(3));
365      iso.style.setProperty('--iso-y', y.toFixed(3));
366    });
367
368    heroSection.addEventListener('mouseleave', () => {
369      iso.style.setProperty('--iso-x', 0);
370      iso.style.setProperty('--iso-y', 0);
371    });
372  }
373
374  const boot2 = () => {
375    try { initCursorGlow();   } catch (e) { console.warn('[fx2] cursor', e); }
376    try { initCountUp();      } catch (e) { console.warn('[fx2] count', e); }
377    try { initMagnetic();     } catch (e) { console.warn('[fx2] magnetic', e); }
378    try { initIsoParallax();  } catch (e) { console.warn('[fx2] iso-parallax', e); }
379  };
380
381  if (document.readyState === 'loading') {
382    document.addEventListener('DOMContentLoaded', boot2);
383  } else {
384    boot2();
385  }
386})();
387
388/* ===================================================================
389 * STICKY CTA — auto-hide when footer is in view (2026-04-29)
390 * The user-prompted "we know you've reached the bottom, get out of
391 * the way" UX. IntersectionObserver on the <footer>; when it enters
392 * the viewport, add .sticky-cta--at-footer; remove on exit.
393 * =================================================================== */
394(() => {
395  if (!('IntersectionObserver' in window)) return;
396  const start = () => {
397    const bar = document.getElementById('stickyCta');
398    const footer = document.querySelector('footer.footer');
399    if (!bar || !footer) return;
400    const obs = new IntersectionObserver((entries) => {
401      const inView = entries[0].isIntersecting;
402      bar.classList.toggle('sticky-cta--at-footer', inView);
403    }, { rootMargin: '0px 0px -40px 0px', threshold: 0 });
404    obs.observe(footer);
405  };
406  if (document.readyState === 'loading') {
407    document.addEventListener('DOMContentLoaded', start);
408  } else {
409    start();
410  }
411})();

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.