1/** 2 * Vanilla JS Parallax - UIkit-compatible implementation 3 * 4 * Mimics the behavior of UIkit's uk-parallax component without the flickering issues. 5 * Uses requestAnimationFrame for smooth animations and throttled updates for efficiency. 6 * 7 * Anti-flicker measures: 8 * - Uses CSS will-change and transform3d for GPU acceleration 9 * - Batches DOM reads and writes 10 * - Throttles updates to avoid excessive recalculations 11 * - Uses passive event listeners 12 * 13 * Supports: 14 * - target: CSS selector for the element to track 15 * - y: translateY animation (e.g., "200, -200" or just "-200") 16 * - x: translateX animation 17 * - scale: scale transform 18 * - rotate: rotation in degrees 19 * - opacity: opacity animation 20 * - start: animation start offset (vh, %, px) 21 * - end: animation end offset (vh, %, px) 22 * - easing: animation easing curve 23 * - media: media query for enabling/disabling 24 */ 25 26(function() { 27 'use strict'; 28 29 // Shared scroll handler for all instances (more efficient) 30 const instances = new Set(); 31 let globalRafId = null; 32 let lastKnownScrollY = window.scrollY; 33 let ticking = false; 34 35 function globalUpdate() { 36 lastKnownScrollY = window.scrollY; 37 instances.forEach(instance => instance.update(lastKnownScrollY)); 38 ticking = false; 39 } 40 41 function requestTick() { 42 if (!ticking) { 43 ticking = true; 44 globalRafId = requestAnimationFrame(globalUpdate); 45 } 46 } 47 48 // Single scroll listener for all instances 49 let scrollListenerAttached = false; 50 function attachGlobalScrollListener() { 51 if (scrollListenerAttached) return; 52 scrollListenerAttached = true; 53 54 window.addEventListener('scroll', requestTick, { passive: true }); 55 window.addEventListener('resize', () => { 56 // Debounce resize 57 clearTimeout(window._parallaxResizeTimer); 58 window._parallaxResizeTimer = setTimeout(requestTick, 100); 59 }, { passive: true }); 60 } 61 62 class VanillaParallax { 63 constructor(element, options = {}) { 64 this.element = element; 65 this.options = this.parseOptions(options); 66 this.target = this.options.target 67 ? document.querySelector(this.options.target) 68 : element; 69 70 this.isActive = true; 71 this.lastProgress = -1; 72 this.cachedTargetRect = null; 73 this.cacheTime = 0; 74 75 this.init(); 76 } 77 78 parseOptions(options) { 79 const defaults = { 80 target: null, 81 y: null, 82 x: null, 83 scale: null, 84 rotate: null, 85 opacity: null, 86 blur: null, 87 bgx: null, 88 bgy: null, 89 start: '0', 90 end: '0', 91 easing: 1, 92 media: false 93 }; 94 95 // If options is a string (from data attribute), parse it 96 if (typeof options === 'string') { 97 options = this.parseAttributeString(options); 98 } 99 100 return { ...defaults, ...options }; 101 } 102 103 parseAttributeString(str) { 104 const options = {}; 105 // Parse "key: value; key2: value2" format 106 const pairs = str.split(';').map(s => s.trim()).filter(Boolean); 107 108 pairs.forEach(pair => { 109 const colonIndex = pair.indexOf(':'); 110 if (colonIndex > -1) { 111 const key = pair.substring(0, colonIndex).trim(); 112 const value = pair.substring(colonIndex + 1).trim(); 113 options[key] = value; 114 } 115 }); 116 117 return options; 118 } 119 120 parseStops(value) { 121 if (value === null || value === undefined) return null; 122 123 const str = String(value); 124 // Parse comma-separated values like "200, -200" or just "-200" 125 const parts = str.split(',').map(s => s.trim()); 126 127 return parts.map(part => { 128 // Check for position specification like "50 10%" 129 const match = part.match(/^(-?\d+(?:\.\d+)?(?:px|vh|vw|%)?)\s*(\d+%)?$/); 130 if (match) { 131 return { 132 value: this.parseValue(match[1]), 133 position: match[2] ? parseFloat(match[2]) / 100 : null 134 }; 135 } 136 return { 137 value: this.parseValue(part), 138 position: null 139 }; 140 }); 141 } 142 143 parseValue(str) { 144 if (str === null || str === undefined) return 0; 145 str = String(str).trim(); 146 147 // Handle units 148 if (str.endsWith('vh')) { 149 return parseFloat(str) * window.innerHeight / 100; 150 } 151 if (str.endsWith('vw')) { 152 return parseFloat(str) * window.innerWidth / 100; 153 } 154 if (str.endsWith('%')) { 155 return { percent: parseFloat(str) }; 156 } 157 if (str.endsWith('px')) { 158 return parseFloat(str); 159 } 160 161 return parseFloat(str) || 0; 162 } 163 164 parseOffset(str, targetHeight) { 165 if (!str || str === '0') return 0; 166 167 str = String(str).trim(); 168 let total = 0; 169 170 // Handle expressions like "100% + 100" or "30vh" 171 const parts = str.match(/([+-]?\s*\d+(?:\.\d+)?(?:vh|vw|%|px)?)/g) || []; 172
173 parts.forEach(part => { 174 part = part.trim(); 175 if (part.endsWith('vh')) { 176 total += parseFloat(part) * window.innerHeight / 100; 177 } else if (part.endsWith('vw')) { 178 total += parseFloat(part) * window.innerWidth / 100; 179 } else if (part.endsWith('%')) { 180 total += parseFloat(part) * targetHeight / 100; 181 } else if (part.endsWith('px')) { 182 total += parseFloat(part); 183 } else { 184 total += parseFloat(part) || 0; 185 } 186 }); 187 188 return total; 189 } 190 191 init() { 192 // Check media query 193 if (!this.checkMedia()) { 194 this.isActive = false; 195 return; 196 } 197 198 // Set initial styles for GPU acceleration (prevents flickering) 199 this.element.style.willChange = 'transform, opacity'; 200 this.element.style.backfaceVisibility = 'hidden'; 201 this.element.style.perspective = '1000px'; 202 203 // Register with global handler 204 instances.add(this); 205 attachGlobalScrollListener(); 206 207 // Initial update 208 this.update(window.scrollY); 209 } 210 211 checkMedia() { 212 const media = this.options.media; 213 if (!media) return true; 214 215 // Handle breakpoint shortcuts 216 const breakpoints = { 217 '@s': '(min-width: 640px)', 218 '@m': '(min-width: 960px)', 219 '@l': '(min-width: 1200px)', 220 '@xl': '(min-width: 1600px)' 221 }; 222 223 let query = breakpoints[media] || media; 224 225 // Handle numeric values 226 if (typeof media === 'number' || /^\d+$/.test(media)) { 227 query = `(min-width: ${media}px)`; 228 } 229 230 return window.matchMedia(query).matches; 231 } 232 233 update(scrollY) { 234 if (!this.isActive) return; 235 236 // Recheck media query on each update 237 if (this.options.media && !this.checkMedia()) { 238 this.resetStyles(); 239 return; 240 } 241 242 const progress = this.calculateProgress(); 243 244 // Only update if progress actually changed (prevents flickering) 245 if (Math.abs(progress - this.lastProgress) < 0.0001) return; 246 this.lastProgress = progress; 247 248 this.applyTransforms(progress); 249 } 250 251 calculateProgress() { 252 const rect = this.target.getBoundingClientRect(); 253 const targetHeight = this.target.offsetHeight; 254 const viewportHeight = window.innerHeight; 255 256 // Parse start and end offsets 257 const startOffset = this.parseOffset(this.options.start, targetHeight); 258 const endOffset = this.parseOffset(this.options.end, targetHeight); 259 260 // Calculate animation range 261 // Default: starts when target top enters viewport bottom, ends when target bottom leaves viewport top 262 const animationStart = viewportHeight + startOffset; 263 const animationEnd = -targetHeight - endOffset; 264 265 // Current position relative to viewport 266 const currentPos = rect.top; 267 268 // Calculate raw progress (0 to 1) 269 const totalDistance = animationStart - animationEnd; 270 let progress = (animationStart - currentPos) / totalDistance; 271 272 // Clamp progress between 0 and 1 273 progress = Math.max(0, Math.min(1, progress)); 274 275 // Apply easing 276 progress = this.applyEasing(progress); 277 278 return progress; 279 } 280 281 applyEasing(progress) { 282 const easing = parseFloat(this.options.easing) || 1; 283 284 if (easing === 0) { 285 return progress; 286 } else if (easing < 0) { 287 // Starts fast, slows down 288 return 1 - Math.pow(1 - progress, 1 - easing); 289 } else { 290 // Starts slow, speeds up 291 return Math.pow(progress, 1 + easing); 292 } 293 } 294 295 interpolate(stops, progress, defaultStart = 0) { 296 if (!stops || stops.length === 0) return null; 297 298 // Single value means animate from defaultStart to that value 299 if (stops.length === 1) { 300 const endValue = typeof stops[0].value === 'object' 301 ? stops[0].value.percent 302 : stops[0].value; 303 return defaultStart + (endValue - defaultStart) * progress; 304 } 305 306 // Multiple stops - find the two we're between 307 const positions = stops.map((stop, i) => { 308 if (stop.position !== null) return stop.position; 309 // Evenly distribute stops without explicit positions 310 return i / (stops.length - 1); 311 }); 312 313 // Find which segment we're in 314 let startIdx = 0; 315 for (let i = 0; i < positions.length - 1; i++) { 316 if (progress >= positions[i] && progress <= positions[i + 1]) { 317 startIdx = i; 318 break; 319 } 320 if (progress > positions[i]) { 321 startIdx = i; 322 } 323 } 324 325 const endIdx = Math.min(startIdx + 1, stops.length - 1); 326 327 // Calculate local progress within this segment 328 const segmentStart = positions[startIdx]; 329 const segmentEnd = positions[endIdx]; 330 const segmentProgress = segmentEnd === segmentStart 331 ? 1 332 : (progress - segmentStart) / (segmentEnd - segmentStart); 333 334 const startValue = typeof stops[startIdx].value === 'object' 335 ? stops[startIdx].value.percent 336 : stops[startIdx].value; 337 const endValue = typeof stops[endIdx].value === 'object' 338 ? stops[endIdx].value.percent 339 : stops[endIdx].value; 340 341 return startValue + (endValue - startValue) * segmentProgress; 342 } 343 344 applyTransforms(progress) { 345 let translateX = 0; 346 let translateY = 0; 347 let scale = 1; 348 let rotate = 0; 349 const filters = []; 350 let styles = {}; 351 352 // Y translation 353 if (this.options.y !== null) { 354 const stops = this.parseStops(this.options.y); 355 const value = this.interpolate(stops, progress, 0); 356 if (value !== null) { 357 translateY = value; 358 } 359 } 360 361 // X translation 362 if (this.options.x !== null) { 363 const stops = this.parseStops(this.options.x); 364 const value = this.interpolate(stops, progress, 0); 365 if (value !== null) { 366 translateX = value; 367 } 368 } 369 370 // Scale 371 if (this.options.scale !== null) { 372 const stops = this.parseStops(this.options.scale); 373 const value = this.interpolate(stops, progress, 1); 374 if (value !== null) { 375 scale = value; 376 } 377 } 378 379 // Rotate 380 if (this.options.rotate !== null) { 381 const stops = this.parseStops(this.options.rotate); 382 const value = this.interpolate(stops, progress, 0); 383 if (value !== null) { 384 rotate = value; 385 } 386 } 387 388 // Opacity 389 if (this.options.opacity !== null) { 390 const stops = this.parseStops(this.options.opacity); 391 const value = this.interpolate(stops, progress, 1); 392 if (value !== null) { 393 styles.opacity = value; 394 } 395 } 396 397 // Blur filter 398 if (this.options.blur !== null) { 399 const stops = this.parseStops(this.options.blur); 400 const value = this.interpolate(stops, progress, 0); 401 if (value !== null) { 402 filters.push(`blur(${value}px)`); 403 } 404 } 405
406 // Background position Y 407 if (this.options.bgy !== null) { 408 const stops = this.parseStops(this.options.bgy); 409 const value = this.interpolate(stops, progress, 0); 410 if (value !== null) { 411 const currentBgPos = window.getComputedStyle(this.element).backgroundPosition; 412 const xPos = currentBgPos.split(' ')[0] || '50%'; 413 styles.backgroundPosition = `${xPos} calc(50% + ${value}px)`; 414 } 415 } 416 417 // Background position X 418 if (this.options.bgx !== null) { 419 const stops = this.parseStops(this.options.bgx); 420 const value = this.interpolate(stops, progress, 0); 421 if (value !== null) { 422 const currentBgPos = window.getComputedStyle(this.element).backgroundPosition; 423 const yPos = currentBgPos.split(' ')[1] || '50%'; 424 styles.backgroundPosition = `calc(50% + ${value}px) ${yPos}`; 425 } 426 } 427 428 // Build transform string using translate3d for GPU acceleration (anti-flicker) 429 const transforms = []; 430 transforms.push(`translate3d(${translateX}px, ${translateY}px, 0)`); 431 if (scale !== 1) { 432 transforms.push(`scale(${scale})`); 433 } 434 if (rotate !== 0) { 435 transforms.push(`rotate(${rotate}deg)`); 436 } 437 438 // Apply all transforms in one write operation (batch DOM writes) 439 this.element.style.transform = transforms.join(' '); 440 441 // Apply filters 442 if (filters.length > 0) { 443 this.element.style.filter = filters.join(' '); 444 } 445 446 // Apply other styles 447 Object.keys(styles).forEach(key => { 448 this.element.style[key] = styles[key]; 449 }); 450 } 451 452 resetStyles() { 453 this.element.style.transform = ''; 454 this.element.style.opacity = ''; 455 this.element.style.filter = ''; 456 this.element.style.backgroundPosition = ''; 457 } 458 459 destroy() { 460 this.isActive = false; 461 instances.delete(this); 462 this.resetStyles(); 463 this.element.style.willChange = ''; 464 this.element.style.backfaceVisibility = ''; 465 this.element.style.perspective = ''; 466 } 467 } 468 469 // Auto-initialize from data attributes 470 function initFromAttributes() { 471 // Support both data-parallax and data-gt-parallax attributes 472 const elements = document.querySelectorAll('[data-gt-parallax], [data-parallax]'); 473 474 elements.forEach(element => { 475 const options = element.dataset.gtParallax || element.dataset.parallax; 476 new VanillaParallax(element, options); 477 }); 478 } 479 480 // Initialize elements with gt-parallax attribute 481 function initGtParallax() { 482 const elements = document.querySelectorAll('[gt-parallax]'); 483 484 elements.forEach(element => { 485 const options = element.getAttribute('gt-parallax'); 486 new VanillaParallax(element, options); 487 }); 488 } 489 490 // Export for manual use 491 window.VanillaParallax = VanillaParallax; 492 493 // Auto-init on DOM ready 494 if (document.readyState === 'loading') { 495 document.addEventListener('DOMContentLoaded', () => { 496 initGtParallax(); 497 initFromAttributes(); 498 }); 499 } else { 500 initGtParallax(); 501 initFromAttributes(); 502 } 503 504})();
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.