From 7bad958a3879ccd87fd9d37613d75326764ed08d Mon Sep 17 00:00:00 2001
From: Linus Olofsson <61837116+LinusOlofsson-maker@users.noreply.github.com>
Date: Wed, 20 May 2026 22:56:26 +0200
Subject: [PATCH] feat(pulse): slower, brighter, comet-shaped pulse along paths
Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.
Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.
---
src/components/WaveBackground.astro | 141 ++++++++++++++++++++--------
1 file changed, 100 insertions(+), 41 deletions(-)
diff --git a/src/components/WaveBackground.astro b/src/components/WaveBackground.astro
index d343e09..5a5a137 100644
--- a/src/components/WaveBackground.astro
+++ b/src/components/WaveBackground.astro
@@ -4,8 +4,9 @@ const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5';
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee';
-const pulseMain = theme === 'purple' ? '#e879f9' : '#67e8f9';
-const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
+const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc';
+const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe';
+const pulseCore = '#ffffff';
---
-
@@ -65,12 +88,24 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
const thread1 = document.getElementById('thread1') as SVGPathElement | null;
const thread2 = document.getElementById('thread2') as SVGPathElement | null;
const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null;
- const blob = document.getElementById('pulse-blob') as SVGCircleElement | null;
+ const blobGroup = document.getElementById('pulse-blob') as SVGGElement | null;
+ const halo = document.getElementById('pulse-halo') as SVGEllipseElement | null;
+ const mid = document.getElementById('pulse-mid') as SVGEllipseElement | null;
+ const core = document.getElementById('pulse-core') as SVGEllipseElement | null;
const boxGroup = document.getElementById('box-trail-group') as SVGGElement | null;
const wrapper = svg?.parentElement;
const speeds: Speeds = { w1: 0.5, w2: -0.8, w3: 1.2, t1: 1.8, t2: -1.5 };
+ function getSpeedKey(id: string): keyof Speeds | null {
+ if (id === 'wave1') return 'w1';
+ if (id === 'wave2') return 'w2';
+ if (id === 'wave3') return 'w3';
+ if (id === 'thread1') return 't1';
+ if (id === 'thread2') return 't2';
+ return null;
+ }
+
function onScroll() {
const scrollY = window.scrollY;
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
@@ -81,15 +116,15 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
}
window.addEventListener('scroll', onScroll, { passive: true });
- if (svg && blob && wrapper) {
- const pulseMain = wrapper.getAttribute('data-pulse-main') || '#67e8f9';
- const pulseSec = wrapper.getAttribute('data-pulse-sec') || '#a5f3fc';
+ if (svg && blobGroup && halo && mid && core && wrapper) {
+ const pulseMain = wrapper.getAttribute('data-pulse-main') || '#a5f3fc';
+ const pulseSec = wrapper.getAttribute('data-pulse-sec') || '#cffafe';
const wavePaths = Array.from(document.querySelectorAll('[data-pulse-path="true"]'));
const NS = 'http://www.w3.org/2000/svg';
function buildBoxPath(rect: DOMRect): string {
- const r = 14;
+ const r = 16;
const x = rect.left;
const y = rect.top;
const w = rect.width;
@@ -134,11 +169,26 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
return created;
}
+ function updatePulseColors(isSecondary: boolean) {
+ const haloColor = isSecondary ? pulseSec : pulseMain;
+ const midColor = isSecondary ? pulseMain : pulseSec;
+
+ const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
+ if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.85`);
+ if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.35`);
+ if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${haloColor};stop-opacity:0`);
+
+ const midStops = document.querySelectorAll('#pulseMidGradient stop');
+ if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${midColor};stop-opacity:1`);
+ if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${midColor};stop-opacity:0.5`);
+ if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${midColor};stop-opacity:0`);
+ }
+
let currentTimeout: number | null = null;
let isPulsing = false;
- function runPulse(path: SVGPathElement, duration = 4500) {
- if (!blob) return;
+ function runPulse(path: SVGPathElement, duration = 9000) {
+ if (!blobGroup || !halo || !mid || !core) return;
isPulsing = true;
const length = path.getTotalLength();
const startTime = performance.now();
@@ -146,42 +196,45 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
(path.getAttribute('stroke') || '').includes('gradSec') ||
path.id === 'thread1' ||
path.id === 'thread2';
- const color = isSecondary ? pulseSec : pulseMain;
+ updatePulseColors(isSecondary);
- const grad = document.querySelector('#pulseGradient stop:first-child') as SVGStopElement | null;
- const gradMid = document.querySelectorAll('#pulseGradient stop')[1] as SVGStopElement | null;
- if (grad) grad.setAttribute('style', `stop-color:${color};stop-opacity:1`);
- if (gradMid) gradMid.setAttribute('style', `stop-color:${color};stop-opacity:0.4`);
+ const speedKey = getSpeedKey(path.id);
function step(now: number) {
- if (!blob) return;
+ if (!blobGroup || !halo || !mid || !core) return;
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
- const pt = path.getPointAtLength(t * length);
+ const eased = t < 0.5
+ ? 2 * t * t
+ : 1 - Math.pow(-2 * t + 2, 2) / 2;
- let pathTx = 0;
- const speedKey =
- path.id === 'wave1' ? 'w1'
- : path.id === 'wave2' ? 'w2'
- : path.id === 'wave3' ? 'w3'
- : path.id === 'thread1' ? 't1'
- : path.id === 'thread2' ? 't2'
- : null;
- if (speedKey) pathTx = window.scrollY * speeds[speedKey as keyof Speeds];
+ const pt = path.getPointAtLength(eased * length);
- blob.setAttribute('cx', String(pt.x + pathTx));
- blob.setAttribute('cy', String(pt.y));
+ const sampleDelta = Math.max(1, length * 0.002);
+ const before = path.getPointAtLength(Math.max(0, eased * length - sampleDelta));
+ const after = path.getPointAtLength(Math.min(length, eased * length + sampleDelta));
+ const dx = after.x - before.x;
+ const dy = after.y - before.y;
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI);
+
+ const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
+ const cx = pt.x + pathTx;
+ const cy = pt.y;
+
+ blobGroup.setAttribute('transform', `translate(${cx} ${cy}) rotate(${angle})`);
let opacity = 0;
- if (t < 0.15) opacity = t / 0.15;
- else if (t > 0.85) opacity = (1 - t) / 0.15;
+ if (t < 0.18) opacity = (t / 0.18);
+ else if (t > 0.82) opacity = ((1 - t) / 0.18);
else opacity = 1;
- blob.setAttribute('opacity', String(opacity * 0.9));
+ opacity = Math.max(0, Math.min(1, opacity));
+
+ blobGroup.setAttribute('opacity', String(opacity));
if (t < 1) {
requestAnimationFrame(step);
} else {
- blob.setAttribute('opacity', '0');
+ blobGroup.setAttribute('opacity', '0');
isPulsing = false;
}
}
@@ -201,13 +254,16 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
}
const choice = allTargets[Math.floor(Math.random() * allTargets.length)];
const isBox = choice.getAttribute('data-trail-box-path') === 'true';
- runPulse(choice, isBox ? 3500 : 4500 + Math.random() * 2000);
+ const duration = isBox
+ ? 7000 + Math.random() * 2000
+ : 9000 + Math.random() * 4000;
+ runPulse(choice, duration);
schedule();
}
function schedule() {
if (currentTimeout) clearTimeout(currentTimeout);
- const delay = 2500 + Math.random() * 4500;
+ const delay = 3500 + Math.random() * 5500;
currentTimeout = window.setTimeout(pickAndPulse, delay);
}
@@ -222,7 +278,10 @@ const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
will-change: transform;
}
#pulse-blob {
- transition: opacity 0.05s linear;
mix-blend-mode: screen;
}
+ #pulse-blob ellipse {
+ transform-box: fill-box;
+ transform-origin: center;
+ }