PageSourceSearch

https://tomrath.org/engine.js

js tomrath.org collected 2026-10-04 00:15:35 UTC 10,799 bytes, 316 lines download raw bytes

1/* ============================================
2   TOM RATH — ANIMATION ENGINE v2.0
3   GSAP-Level Scroll Animations, Text Splitting,
4   Magnetic Cursor, Parallax, Counters
5   ============================================ */
6
7(function() {
8  'use strict';
9
10  // ─── SCROLL PROGRESS BAR ───
11  const progressBar = document.querySelector('.scroll-progress');
12  if (progressBar) {
13    window.addEventListener('scroll', () => {
14      const scrollTop = window.scrollY;
15      const docHeight = document.documentElement.scrollHeight - window.innerHeight;
16      const progress = docHeight > 0 ? scrollTop / docHeight : 0;
17      progressBar.style.transform = `scaleX(${progress})`;
18    }, { passive: true });
19  }
20
21  // ─── NAVIGATION ───
22  const nav = document.querySelector('.nav');
23  const hamburger = document.querySelector('.nav__hamburger');
24  const mobileOverlay = document.querySelector('.mobile-overlay');
25
26  if (nav) {
27    window.addEventListener('scroll', () => {
28      nav.classList.toggle('scrolled', window.scrollY > 60);
29    }, { passive: true });
30  }
31
32  if (hamburger && mobileOverlay) {
33    function toggleMenu() {
34      hamburger.classList.toggle('active');
35      mobileOverlay.classList.toggle('active');
36      const isOpen = mobileOverlay.classList.contains('active');
37      document.body.style.overflow = isOpen ? 'hidden' : '';
38      hamburger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
39      if (isOpen) {
40        const firstLink = mobileOverlay.querySelector('a');
41        if (firstLink) firstLink.focus();
42      }
43    }
44
45    hamburger.addEventListener('click', toggleMenu);
46
47    // Keyboard support: Enter and Space to toggle
48    hamburger.addEventListener('keydown', (e) => {
49      if (e.key === 'Enter' || e.key === ' ') {
50        e.preventDefault();
51        toggleMenu();
52      }
53    });
54
55    // H3: Escape key to close mobile overlay
56    document.addEventListener('keydown', (e) => {
57      if (e.key === 'Escape' && mobileOverlay.classList.contains('active')) {
58        toggleMenu();
59        hamburger.focus();
60      }
61    });
62
63    mobileOverlay.querySelectorAll('a').forEach(link => {
64      link.addEventListener('click', () => {
65        hamburger.classList.remove('active');
66        mobileOverlay.classList.remove('active');
67        document.body.style.overflow = '';
68      });
69    });
70  }
71
72  // ─── SCROLL REVEAL (IntersectionObserver) ───
73  const revealObserver = new IntersectionObserver((entries) => {
74    entries.forEach(entry => {
75      if (entry.isIntersecting) {
76        entry.target.classList.add('revealed');
77        revealObserver.unobserve(entry.target);
78      }
79    });
80  }, {
81    threshold: 0.1,
82    rootMargin: '0px 0px -80px 0px'
83  });
84
85  document.querySelectorAll('[data-reveal], [data-stagger]').forEach(el => {
86    revealObserver.observe(el);
87  });
88
89  // ─── TEXT SPLITTING ANIMATION ───
90  document.querySelectorAll('[data-split]').forEach(el => {
91    const text = el.textContent;
92    const words = text.split(' ');
93    el.innerHTML = '';
94    el.setAttribute('aria-label', text);
95
96    words.forEach((word, wi) => {
97      const wordSpan = document.createElement('span');
98      wordSpan.className = 'word';
99      wordSpan.style.display = 'inline-block';
100      wordSpan.style.overflow = 'hidden';
101
102      [...word].forEach((char, ci) => {
103        const charSpan = document.createElement('span');
104        charSpan.className = 'char';
105        charSpan.textContent = char;
106        charSpan.style.display = 'inline-block';
107        charSpan.style.opacity = '0';
108        charSpan.style.transform = 'translateY(100%)';
109        charSpan.style.animationDelay = `${(wi * 0.08) + (ci * 0.03) + 0.3}s`;
110        wordSpan.appendChild(charSpan);
111      });
112
113      el.appendChild(wordSpan);
114      if (wi < words.length - 1) {
115        el.appendChild(document.createTextNode(' '));
116      }
117    });
118
119    // Trigger animation when in view
120    const splitObserver = new IntersectionObserver((entries) => {
121      entries.forEach(entry => {
122        if (entry.isIntersecting) {
123          entry.target.querySelectorAll('.char').forEach(char => {
124            char.style.animation = `charReveal 0.6s var(--ease-out) forwards`;
125          });
126          splitObserver.unobserve(entry.target);
127        }
128      });
129    }, { threshold: 0.3 });
130
131    splitObserver.observe(el);
132  });
133
134  // ─── COUNTER ANIMATION ───
135  document.querySelectorAll('[data-count]').forEach(el => {
136    const target = parseInt(el.dataset.count, 10);
137    const suffix = el.dataset.suffix || '';
138    const prefix = el.dataset.prefix || '';
139    const duration = parseInt(el.dataset.duration || '2000', 10);
140    let started = false;
141
142    const countObserver = new IntersectionObserver((entries) => {
143      entries.forEach(entry => {
144        if (entry.isIntersecting && !started) {
145          started = true;
146          animateCount(el, 0, target, duration, prefix, suffix);
147          countObserver.unobserve(entry.target);
148        }
149      });
150    }, { threshold: 0.5 });
151
152    countObserver.observe(el);
153  });
154
155  function animateCount(el, start, end, duration, prefix, suffix) {
156    const startTime = performance.now();
157    function update(currentTime) {
158      const elapsed = currentTime - startTime;
159      const progress = Math.min(elapsed / duration, 1);
160      // Ease out cubic
161      const eased = 1 - Math.pow(1 - progress, 3);
162      const current = Math.round(start + (end - start) * eased);
163      el.textContent = prefix + current.toLocaleString() + suffix;
164      if (progress < 1) {
165        requestAnimationFrame(update);
166      }
167    }
168    requestAnimationFrame(update);
169  }
170
171  // ─── PARALLAX ───
172  const parallaxElements = document.querySelectorAll('[data-parallax]');
173  if (parallaxElements.length > 0) {
174    let ticking = false;
175    window.addEventListener('scroll', () => {
176      if (!ticking) {
177        requestAnimationFrame(() => {
178          const scrollY = window.scrollY;
179          parallaxElements.forEach(el => {
180            const speed = parseFloat(el.dataset.parallax) || 0.3;
181            const rect = el.getBoundingClientRect();
182            const centerY = rect.top + rect.height / 2;
183            const viewportCenter = window.innerHeight / 2;
184            const offset = (centerY - viewportCenter) * speed;
185            el.style.transform = `translateY(${offset}px)`;
186          });
187          ticking = false;
188        });
189        ticking = true;
190      }
191    }, { passive: true });
192  }
193
194  // ─── MAGNETIC CURSOR EFFECT ───
195  if (window.matchMedia('(pointer: fine)').matches && window.innerWidth > 768) {
196    document.querySelectorAll('[data-magnetic]').forEach(el => {
197      const strength = parseFloat(el.dataset.magnetic) || 0.3;
198
199      el.addEventListener('mousemove', (e) => {
200        const rect = el.getBoundingClientRect();
201        const x = e.clientX - rect.left - rect.width / 2;
202        const y = e.clientY - rect.top - rect.height / 2;
203        el.style.transform = `translate(${x * strength}px, ${y * strength}px)`;
204      });
205
206      el.addEventListener('mouseleave', () => {
207        el.style.transform = '';
208      });
209    });
210
211  }
212
213  // ─── TILT EFFECT ON BOOK COVERS ───
214  document.querySelectorAll('[data-tilt]').forEach(el => {
215    const maxTilt = parseFloat(el.dataset.tilt) || 15;
216
217    el.addEventListener('mousemove', (e) => {
218      const rect = el.getBoundingClientRect();
219      const x = (e.clientX - rect.left) / rect.width;
220      const y = (e.clientY - rect.top) / rect.height;
221      const rotateX = (0.5 - y) * maxTilt;
222      const rotateY = (x - 0.5) * maxTilt;
223      el.style.transform = `perspective(800px) rotateX(${rotateX}deg) rotateY(${rotateY}deg) scale(1.02)`;
224    });
225
226    el.addEventListener('mouseleave', () => {
227      el.style.transform = '';
228      el.style.transition = 'transform 0.5s var(--ease-out)';
229      setTimeout(() => { el.style.transition = ''; }, 500);
230    });
231  });
232
233  // ─── SMOOTH SECTION TRANSITIONS ───
234  document.querySelectorAll('a[href^="#"]').forEach(anchor => {
235    anchor.addEventListener('click', function(e) {
236      e.preventDefault();
237      const target = document.querySelector(this.getAttribute('href'));
238      if (target) {
239        target.scrollIntoView({ behavior: 'smooth', block: 'start' });
240      }
241    });
242  });
243
244  // ─── BACK TO TOP ───
245  const backToTop = document.querySelector('.back-to-top');
246  if (backToTop) {
247    window.addEventListener('scroll', () => {
248      backToTop.classList.toggle('visible', window.scrollY > 600);
249    }, { passive: true });
250
251    backToTop.addEventListener('click', () => {
252      window.scrollTo({ top: 0, behavior: 'smooth' });
253    });
254  }
255
256  // ─── STAGGER ANIMATION FOR GRID CHILDREN ───
257  document.querySelectorAll('[data-stagger-grid]').forEach(grid => {
258    const gridObserver = new IntersectionObserver((entries) => {
259      entries.forEach(entry => {
260        if (entry.isIntersecting) {
261          const children = entry.target.children;
262          Array.from(children).forEach((child, i) => {
263            child.style.transition = `opacity 0.6s var(--ease-out) ${i * 0.1}s, transform 0.6s var(--ease-out) ${i * 0.1}s`;
264            child.style.opacity = '1';
265            child.style.transform = 'translateY(0)';
266          });
267          gridObserver.unobserve(entry.target);
268        }
269      });
270    }, { threshold: 0.1 });
271
272    // Initially hide children
273    Array.from(grid.children).forEach(child => {
274      child.style.opacity = '0';
275      child.style.transform = 'translateY(30px)';
276    });
277
278    gridObserver.observe(grid);
279  });
280
281  // ─── HORIZONTAL SCROLL REVEAL FOR LOGOS/PRESS BAR ───
282  document.querySelectorAll('[data-marquee]').forEach(el => {
283    const inner = el.querySelector('.marquee-inner');
284    if (inner) {
285      // Clone children for seamless loop
286      const clone = inner.cloneNode(true);
287      clone.setAttribute('aria-hidden', 'true');
288      el.appendChild(clone);
289    }
290  });
291
292  // Page transition effect removed — the outgoing opacity fade
293  // caused persistent white screens on back-button navigation
294  // because bfcache preserved the opacity:0 state and no JS
295  // handler reliably fired to restore visibility.
296
297  // ─── LAZY IMAGE BLUR-UP ───
298  document.querySelectorAll('img[data-src]').forEach(img => {
299    const observer = new IntersectionObserver((entries) => {
300      entries.forEach(entry => {
301        if (entry.isIntersecting) {
302          const image = entry.target;
303          image.src = image.dataset.src;
304          image.onload = () => {
305            image.style.filter = 'none';
306            image.style.transition = 'filter 0.5s ease-out';
307          };
308          observer.unobserve(image);
309        }
310      });
311    });
312    img.style.filter = 'blur(10px)';
313    observer.observe(img);
314  });
315
316})();

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.