| <!-- PretextBackground.svelte — Pretext-inspired dynamic floating text background |
| Words (MAC, MBM, AI) float and scatter on hover/touch with physics --> |
| <script> |
| import { onMount, onDestroy } from 'svelte'; |
|
|
| let containerEl; |
| let rafId; |
| let words = []; |
| let mouse = { x: -9999, y: -9999, active: false }; |
|
|
| // Word configuration — scattered across viewport |
| const WORD_CONFIG = [ |
| { text: 'MAC', x: 0.07, y: 0.08, size: 42, rot: -8, weight: 900 }, |
| { text: 'MBM', x: 0.82, y: 0.12, size: 20, rot: 5, weight: 700 }, |
| { text: 'AI', x: 0.42, y: 0.05, size: 26, rot: -12, weight: 800 }, |
| { text: 'MAC', x: 0.91, y: 0.35, size: 34, rot: 3, weight: 900 }, |
| { text: 'MBM', x: 0.15, y: 0.45, size: 16, rot: -6, weight: 600 }, |
| { text: 'AI', x: 0.68, y: 0.22, size: 42, rot: 10, weight: 900 }, |
| { text: 'MAC', x: 0.55, y: 0.72, size: 20, rot: -3, weight: 700 }, |
| { text: 'MBM', x: 0.28, y: 0.78, size: 26, rot: 7, weight: 800 }, |
| { text: 'AI', x: 0.78, y: 0.55, size: 16, rot: -14, weight: 600 }, |
| { text: 'MAC', x: 0.04, y: 0.65, size: 34, rot: 4, weight: 900 }, |
| { text: 'MBM', x: 0.60, y: 0.88, size: 42, rot: -9, weight: 900 }, |
| { text: 'AI', x: 0.88, y: 0.78, size: 20, rot: 6, weight: 700 }, |
| { text: 'MAC', x: 0.35, y: 0.90, size: 16, rot: -5, weight: 600 }, |
| { text: 'MBM', x: 0.18, y: 0.22, size: 26, rot: 11, weight: 800 }, |
| { text: 'AI', x: 0.48, y: 0.42, size: 34, rot: -7, weight: 900 }, |
| { text: 'MAC', x: 0.72, y: 0.60, size: 20, rot: 2, weight: 700 }, |
| { text: 'MBM', x: 0.95, y: 0.15, size: 16, rot: -13, weight: 600 }, |
| { text: 'AI', x: 0.08, y: 0.88, size: 26, rot: 9, weight: 800 }, |
| { text: 'MAC', x: 0.50, y: 0.18, size: 42, rot: -4, weight: 900 }, |
| { text: 'MBM', x: 0.38, y: 0.55, size: 20, rot: 13, weight: 700 }, |
| { text: 'CLOUD', x: 0.22, y: 0.35, size: 16, rot: -2, weight: 600 }, |
| { text: 'GPU', x: 0.75, y: 0.42, size: 16, rot: 8, weight: 600 }, |
| { text: 'LLM', x: 0.62, y: 0.68, size: 16, rot: -6, weight: 600 }, |
| ]; |
|
|
| // Physics constants |
| const REPEL_RADIUS = 200; |
| const MAX_PUSH = 80; |
| const SPRING = 0.05; |
| const FRICTION = 0.82; |
| const PROXIMITY_GLOW_RADIUS = 160; |
|
|
| function initWords() { |
| if (!containerEl) return; |
| const rect = containerEl.getBoundingClientRect(); |
| words = WORD_CONFIG.map((cfg, i) => ({ |
| ...cfg, |
| origX: cfg.x * rect.width, |
| origY: cfg.y * rect.height, |
| currX: cfg.x * rect.width, |
| currY: cfg.y * rect.height, |
| vx: 0, |
| vy: 0, |
| opacity: 0.10, |
| el: null, |
| idx: i, |
| })); |
| } |
|
|
| function physicsLoop() { |
| if (!containerEl) return; |
| let anyMoving = false; |
|
|
| words.forEach(w => { |
| if (!w.el) return; |
|
|
| // Distance from mouse |
| const dx = w.currX - mouse.x; |
| const dy = w.currY - mouse.y; |
| const dist = Math.sqrt(dx * dx + dy * dy); |
|
|
| // Proximity glow |
| let targetOpacity = 0.10; |
| if (mouse.active && dist < PROXIMITY_GLOW_RADIUS) { |
| targetOpacity = 0.15 + (1 - dist / PROXIMITY_GLOW_RADIUS) * 0.30; |
| } |
| w.opacity += (targetOpacity - w.opacity) * 0.1; |
|
|
| // Repel force from mouse |
| if (mouse.active && dist < REPEL_RADIUS && dist > 1) { |
| const force = ((REPEL_RADIUS - dist) / REPEL_RADIUS) * MAX_PUSH; |
| w.vx += (dx / dist) * force * 0.06; |
| w.vy += (dy / dist) * force * 0.06; |
| } |
|
|
| // Spring back to origin |
| w.vx += (w.origX - w.currX) * SPRING; |
| w.vy += (w.origY - w.currY) * SPRING; |
|
|
| // Friction |
| w.vx *= FRICTION; |
| w.vy *= FRICTION; |
|
|
| // Update position |
| w.currX += w.vx; |
| w.currY += w.vy; |
|
|
| // Apply transform |
| const deltaX = w.currX - w.origX; |
| const deltaY = w.currY - w.origY; |
| w.el.style.transform = `rotate(${w.rot}deg) translate(${deltaX}px, ${deltaY}px)`; |
| w.el.style.opacity = w.opacity; |
|
|
| if (Math.abs(w.vx) > 0.05 || Math.abs(w.vy) > 0.05) anyMoving = true; |
| }); |
|
|
| if (anyMoving || mouse.active) { |
| rafId = requestAnimationFrame(physicsLoop); |
| } else { |
| rafId = null; |
| } |
| } |
|
|
| function startPhysics() { |
| if (!rafId) { |
| rafId = requestAnimationFrame(physicsLoop); |
| } |
| } |
|
|
| function handleMouseMove(e) { |
| if (!containerEl) return; |
| const rect = containerEl.getBoundingClientRect(); |
| mouse.x = e.clientX - rect.left; |
| mouse.y = e.clientY - rect.top; |
| mouse.active = true; |
| startPhysics(); |
| } |
|
|
| function handleMouseLeave() { |
| mouse.x = -9999; |
| mouse.y = -9999; |
| mouse.active = false; |
| startPhysics(); // let spring-back animate |
| } |
|
|
| function handleTouchMove(e) { |
| if (!e.touches.length || !containerEl) return; |
| const rect = containerEl.getBoundingClientRect(); |
| mouse.x = e.touches[0].clientX - rect.left; |
| mouse.y = e.touches[0].clientY - rect.top; |
| mouse.active = true; |
| startPhysics(); |
| } |
|
|
| function handleTouchEnd() { |
| mouse.x = -9999; |
| mouse.y = -9999; |
| mouse.active = false; |
| startPhysics(); |
| } |
|
|
| function handleResize() { |
| if (!containerEl) return; |
| const rect = containerEl.getBoundingClientRect(); |
| words.forEach((w, i) => { |
| const cfg = WORD_CONFIG[i]; |
| w.origX = cfg.x * rect.width; |
| w.origY = cfg.y * rect.height; |
| w.currX = w.origX; |
| w.currY = w.origY; |
| w.vx = 0; |
| w.vy = 0; |
| }); |
| } |
|
|
| onMount(() => { |
| initWords(); |
| // Bind word elements |
| setTimeout(() => { |
| if (!containerEl) return; |
| const els = containerEl.querySelectorAll('.pt-word'); |
| els.forEach((el, i) => { |
| if (words[i]) words[i].el = el; |
| }); |
| }, 0); |
|
|
| window.addEventListener('resize', handleResize); |
| return () => { |
| window.removeEventListener('resize', handleResize); |
| if (rafId) cancelAnimationFrame(rafId); |
| }; |
| }); |
|
|
| onDestroy(() => { |
| if (rafId) cancelAnimationFrame(rafId); |
| }); |
| </script> |
|
|
| <!-- svelte-ignore a11y-no-static-element-interactions --> |
| <div |
| bind:this={containerEl} |
| class="pretext-bg" |
| aria-hidden="true" |
| on:mousemove={handleMouseMove} |
| on:mouseleave={handleMouseLeave} |
| on:touchmove|passive={handleTouchMove} |
| on:touchend={handleTouchEnd} |
| > |
| <!-- Scanlines overlay --> |
| <div class="pt-scanlines"></div> |
| |
| <!-- Floating words --> |
| { |
| <span |
| class="pt-word" |
| style=" |
| left: {cfg.x * 100}%; |
| top: {cfg.y * 100}%; |
| font-size: {cfg.size}px; |
| font-weight: {cfg.weight}; |
| transform: rotate({cfg.rot}deg); |
| opacity: 0.10; |
| " |
| >{cfg.text}</span> |
| {/each} |
| </div> |
| |
| <style> |
| .pretext-bg { |
| position: fixed; |
| inset: 0; |
| z-index: 0; |
| pointer-events: auto; |
| overflow: hidden; |
| user-select: none; |
| -webkit-tap-highlight-color: transparent; |
| } |
| |
| .pt-scanlines { |
| position: absolute; |
| inset: 0; |
| background: repeating-linear-gradient( |
| to bottom, |
| transparent 0, |
| transparent 5px, |
| rgba(217, 116, 73, 0.018) 6px |
| ); |
| mask-image: linear-gradient(to bottom, transparent, #000 18%, #000 80%, transparent); |
| pointer-events: none; |
| } |
| |
| .pt-word { |
| position: absolute; |
| color: var(--accent, #D97449); |
| font-family: 'Courier New', monospace; |
| letter-spacing: 0.15em; |
| pointer-events: none; |
| will-change: transform, opacity; |
| transition: opacity 0.3s ease; |
| text-transform: uppercase; |
| } |
| |
| @media (prefers-reduced-motion: reduce) { |
| .pt-word { |
| transition: none !important; |
| } |
| } |
| </style> |
| |