feat: random light pulse along threads + box outlines
A blurred light pulse glides along one of the 5 wave threads (wave1/2/3, thread1/2) every 2.5-7s. Color matches the active theme (cyan on home/portfolio/about, pink/magenta on contact). Pulse also follows the outline of designated boxes marked with data-trail-box: - Home: 'Looking for inspiration?' + 'Ready to collaborate?' - Contact: 'Waiting to hear from you' card Boxes are rebuilt on every pulse so they stay aligned during scroll and respect viewport (off-screen boxes are skipped). Pulse uses requestAnimationFrame + path.getPointAtLength for native smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend mode so it blends with the dark background.
This commit is contained in:
@@ -2,15 +2,19 @@
|
|||||||
// src/components/WaveBackground.astro
|
// src/components/WaveBackground.astro
|
||||||
const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
|
const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
|
||||||
|
|
||||||
// Definiera färger baserat på tema
|
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5';
|
||||||
// Cyan = Originalet (Startsidan bas)
|
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee';
|
||||||
// Purple = Contact-sidan (Matchar boxen)
|
const pulseMain = theme === 'purple' ? '#e879f9' : '#67e8f9';
|
||||||
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; // Purple-600 vs Teal-400
|
const pulseSec = theme === 'purple' ? '#f472b6' : '#a5f3fc';
|
||||||
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 vs Cyan-400
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}>
|
<div
|
||||||
<svg class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}
|
||||||
|
data-wave-theme={theme}
|
||||||
|
data-pulse-main={pulseMain}
|
||||||
|
data-pulse-sec={pulseSec}
|
||||||
|
>
|
||||||
|
<svg id="wave-svg" class="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%">
|
<linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||||
<stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} />
|
<stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} />
|
||||||
@@ -23,48 +27,193 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
|
|||||||
<stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} />
|
<stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} />
|
||||||
<stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
|
<stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
|
<filter id="pulseBlur" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feGaussianBlur stdDeviation="6" />
|
||||||
|
</filter>
|
||||||
|
|
||||||
|
<radialGradient id="pulseGradient">
|
||||||
|
<stop offset="0%" style={`stop-color:${pulseMain};stop-opacity:1`} />
|
||||||
|
<stop offset="60%" style={`stop-color:${pulseMain};stop-opacity:0.4`} />
|
||||||
|
<stop offset="100%" style={`stop-color:${pulseMain};stop-opacity:0`} />
|
||||||
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<g id="wave-group">
|
<g id="wave-group">
|
||||||
<path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" />
|
<path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" data-pulse-path="true" />
|
||||||
<path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" />
|
<path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" data-pulse-path="true" />
|
||||||
<path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" />
|
<path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" data-pulse-path="true" />
|
||||||
|
|
||||||
<path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" />
|
<path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" data-pulse-path="true" />
|
||||||
<path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" />
|
<path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" data-pulse-path="true" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g id="box-trail-group"></g>
|
||||||
|
|
||||||
|
<g id="pulse-group">
|
||||||
|
<circle id="pulse-blob" r="14" fill="url(#pulseGradient)" filter="url(#pulseBlur)" opacity="0" />
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const wave1 = document.getElementById('wave1');
|
type Speeds = { w1: number; w2: number; w3: number; t1: number; t2: number };
|
||||||
const wave2 = document.getElementById('wave2');
|
|
||||||
const wave3 = document.getElementById('wave3');
|
|
||||||
const thread1 = document.getElementById('thread1');
|
|
||||||
const thread2 = document.getElementById('thread2');
|
|
||||||
|
|
||||||
// Olika hastigheter för parallax-effekt
|
const wave1 = document.getElementById('wave1') as SVGPathElement | null;
|
||||||
const speeds = {
|
const wave2 = document.getElementById('wave2') as SVGPathElement | null;
|
||||||
w1: 0.5,
|
const wave3 = document.getElementById('wave3') as SVGPathElement | null;
|
||||||
w2: -0.8,
|
const thread1 = document.getElementById('thread1') as SVGPathElement | null;
|
||||||
w3: 1.2,
|
const thread2 = document.getElementById('thread2') as SVGPathElement | null;
|
||||||
t1: 1.8, // Snabbare "random" tråd
|
const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null;
|
||||||
t2: -1.5 // Snabbare "random" tråd åt andra hållet
|
const blob = document.getElementById('pulse-blob') as SVGCircleElement | 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 onScroll() {
|
function onScroll() {
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY;
|
||||||
|
|
||||||
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
|
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
|
||||||
if (wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
|
if (wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
|
||||||
if (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
|
if (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
|
||||||
|
|
||||||
// Random trådarna rör sig mer
|
|
||||||
if (thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`;
|
if (thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`;
|
||||||
if (thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`;
|
if (thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('scroll', onScroll, { passive: true });
|
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';
|
||||||
|
|
||||||
|
const wavePaths = Array.from(document.querySelectorAll<SVGPathElement>('[data-pulse-path="true"]'));
|
||||||
|
const NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
function buildBoxPath(rect: DOMRect): string {
|
||||||
|
const r = 14;
|
||||||
|
const x = rect.left;
|
||||||
|
const y = rect.top;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
return [
|
||||||
|
`M ${x + r} ${y}`,
|
||||||
|
`H ${x + w - r}`,
|
||||||
|
`A ${r} ${r} 0 0 1 ${x + w} ${y + r}`,
|
||||||
|
`V ${y + h - r}`,
|
||||||
|
`A ${r} ${r} 0 0 1 ${x + w - r} ${y + h}`,
|
||||||
|
`H ${x + r}`,
|
||||||
|
`A ${r} ${r} 0 0 1 ${x} ${y + h - r}`,
|
||||||
|
`V ${y + r}`,
|
||||||
|
`A ${r} ${r} 0 0 1 ${x + r} ${y}`,
|
||||||
|
`Z`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBoxGroup() {
|
||||||
|
if (!boxGroup) return;
|
||||||
|
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshBoxPaths(): SVGPathElement[] {
|
||||||
|
if (!boxGroup) return [];
|
||||||
|
clearBoxGroup();
|
||||||
|
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
|
||||||
|
const created: SVGPathElement[] = [];
|
||||||
|
targets.forEach((el, idx) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.width < 20 || rect.height < 20) return;
|
||||||
|
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
|
||||||
|
const p = document.createElementNS(NS, 'path');
|
||||||
|
p.setAttribute('id', `trail-box-${idx}`);
|
||||||
|
p.setAttribute('d', buildBoxPath(rect));
|
||||||
|
p.setAttribute('fill', 'none');
|
||||||
|
p.setAttribute('stroke', 'transparent');
|
||||||
|
p.setAttribute('data-trail-box-path', 'true');
|
||||||
|
boxGroup.appendChild(p);
|
||||||
|
created.push(p);
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentTimeout: number | null = null;
|
||||||
|
let isPulsing = false;
|
||||||
|
|
||||||
|
function runPulse(path: SVGPathElement, duration = 4500) {
|
||||||
|
if (!blob) return;
|
||||||
|
isPulsing = true;
|
||||||
|
const length = path.getTotalLength();
|
||||||
|
const startTime = performance.now();
|
||||||
|
const isSecondary =
|
||||||
|
(path.getAttribute('stroke') || '').includes('gradSec') ||
|
||||||
|
path.id === 'thread1' ||
|
||||||
|
path.id === 'thread2';
|
||||||
|
const color = isSecondary ? pulseSec : pulseMain;
|
||||||
|
|
||||||
|
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`);
|
||||||
|
|
||||||
|
function step(now: number) {
|
||||||
|
if (!blob) return;
|
||||||
|
const elapsed = now - startTime;
|
||||||
|
const t = Math.min(elapsed / duration, 1);
|
||||||
|
const pt = path.getPointAtLength(t * length);
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
blob.setAttribute('cx', String(pt.x + pathTx));
|
||||||
|
blob.setAttribute('cy', String(pt.y));
|
||||||
|
|
||||||
|
let opacity = 0;
|
||||||
|
if (t < 0.15) opacity = t / 0.15;
|
||||||
|
else if (t > 0.85) opacity = (1 - t) / 0.15;
|
||||||
|
else opacity = 1;
|
||||||
|
blob.setAttribute('opacity', String(opacity * 0.9));
|
||||||
|
|
||||||
|
if (t < 1) {
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
} else {
|
||||||
|
blob.setAttribute('opacity', '0');
|
||||||
|
isPulsing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(step);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickAndPulse() {
|
||||||
|
if (isPulsing) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const boxes = refreshBoxPaths();
|
||||||
|
const allTargets: SVGPathElement[] = [...wavePaths, ...boxes];
|
||||||
|
if (allTargets.length === 0) {
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (currentTimeout) clearTimeout(currentTimeout);
|
||||||
|
const delay = 2500 + Math.random() * 4500;
|
||||||
|
currentTimeout = window.setTimeout(pickAndPulse, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDelay = 1500 + Math.random() * 2000;
|
||||||
|
currentTimeout = window.setTimeout(pickAndPulse, startDelay);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -72,4 +221,8 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
|
|||||||
transition: transform 0.1s linear;
|
transition: transform 0.1s linear;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
#pulse-blob {
|
||||||
|
transition: opacity 0.05s linear;
|
||||||
|
mix-blend-mode: screen;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -61,7 +61,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
|||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute -inset-1 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
|
<div class="absolute -inset-1 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
|
||||||
|
|
||||||
<div class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
|
<div data-trail-box class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3>
|
<h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3>
|
||||||
<p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p>
|
<p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
|||||||
|
|
||||||
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
|
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
|
||||||
|
|
||||||
<div class="flex flex-col items-center">
|
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2>
|
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2>
|
||||||
<a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
<a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||||
My Portfolio
|
My Portfolio
|
||||||
@@ -79,7 +79,7 @@ import WaveBackground from '../components/WaveBackground.astro';
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-center">
|
<div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
|
||||||
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2>
|
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2>
|
||||||
<a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
<a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
|
||||||
Contact me
|
Contact me
|
||||||
|
|||||||
Reference in New Issue
Block a user