ZENYNOZenith of Technology
Command Search
Search for a page
/ Components / Glowing Waves Hero

Glowing Waves Hero

Hero

Full-screen canvas hero with animated glowing sine waves, mouse-reactive ripples, theme-aware colors, and a staggered entrance animation.

herocanvasanimationwavesinteractive
Open preview
Live Preview
AI Generation Prompt

Create a full-screen hero section in Svelte 5 (runes) with an HTML5 canvas background that renders 5 animated, glowing sine waves. Each wave should have a different phase offset, amplitude, frequency, and opacity. The wave colors should be resolved from Tailwind/shadcn CSS custom properties (--primary, --accent, --secondary, --foreground, --background) by creating a hidden div and reading its computed backgroundColor, so the component automatically adapts to light/dark mode and theme changes. The canvas should fill the entire viewport (100vw × 100vh) and resize on window resize. Add mouse tracking: as the cursor moves, waves near the cursor position should distort with a ripple effect (sinusoidal displacement proportional to 1 - distance/MOUSE_RADIUS, with MOUSE_AMPLITUDE ≈ 70px and MOUSE_RADIUS ≈ 320px). Use lerp (factor 0.1) to smoothly follow the mouse. Reduce both values by ~85% when prefers-reduced-motion is set. Watch for theme changes using a MutationObserver on document.documentElement (observing class and data-theme attributes), and recompute all wave colors when a change is detected. Overlay centered content: a small badge (icon + text), a large heading with a gradient-highlighted word, a subtext paragraph, two CTA buttons (primary rounded-full + outline rounded-full), a row of feature pills, and a 3-column stats grid in a frosted-glass card. Animate each element in with a stagger (0ms, 120ms, 240ms, 360ms, 480ms, 600ms transition-delay) using opacity + translateY transitions triggered after mount. Use TypeScript strict mode. Inside the $effect canvas setup, assign canvas and ctx to explicitly typed constants (const el: HTMLCanvasElement = canvas; const c: CanvasRenderingContext2D = ctx;) before passing them to inner functions to satisfy TypeScript's closure narrowing.

<script lang="ts">
	import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
	import ZapIcon from '@lucide/svelte/icons/zap';
	import { Button } from '$lib/components/ui/button';

	type Stat = { label: string; value: string };
	type Props = {
		content: {
			badge: string;
			heading: string;
			heading_highlight: string;
			subtext: string;
			primary_cta: { label: string; href?: string };
			secondary_cta: { label: string; href?: string };
			features: string[];
			stats: Stat[];
		};
	};

	let { content }: Props = $props();

	let canvas = $state<HTMLCanvasElement | null>(null);
	let mounted = $state(false);

	function resolveColor(vars: string[], alpha = 1): string {
		const el = document.createElement('div');
		el.style.cssText = 'position:absolute;visibility:hidden;width:1px;height:1px';
		document.body.appendChild(el);
		const computed = getComputedStyle(document.documentElement);
		let result = `rgba(255,255,255,${alpha})`;
		for (const v of vars) {
			if (computed.getPropertyValue(v).trim()) {
				el.style.backgroundColor = `var(${v})`;
				const bg = getComputedStyle(el).backgroundColor;
				if (bg && bg !== 'rgba(0, 0, 0, 0)') {
					const m = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
					result = m ? `rgba(${m[1]},${m[2]},${m[3]},${alpha})` : bg;
					break;
				}
			}
		}
		document.body.removeChild(el);
		return result;
	}

	$effect(() => {
		requestAnimationFrame(() => { mounted = true; });
	});

	$effect(() => {
		if (!canvas) return;
		const el: HTMLCanvasElement = canvas;
		const ctx: CanvasRenderingContext2D | null = el.getContext('2d');
		if (!ctx) return;
		const c: CanvasRenderingContext2D = ctx;

		const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
		const MOUSE_AMPLITUDE = prefersReducedMotion ? 10 : 70;
		const MOUSE_RADIUS = prefersReducedMotion ? 160 : 320;
		const LERP = prefersReducedMotion ? 0.04 : 0.1;

		let raf: number;
		let time = 0;

		const center = { x: 0, y: 0 };
		const target = { x: 0, y: 0 };

		function computeColors() {
			return {
				bgTop: resolveColor(['--background'], 1),
				bgBottom: resolveColor(['--muted', '--background'], 0.95),
				waves: [
					{ offset: 0,            amplitude: 70, frequency: 0.003,  color: resolveColor(['--primary'], 0.8),                           opacity: 0.45 },
					{ offset: Math.PI / 2,  amplitude: 90, frequency: 0.0026, color: resolveColor(['--accent', '--primary'], 0.7),                opacity: 0.35 },
					{ offset: Math.PI,      amplitude: 60, frequency: 0.0034, color: resolveColor(['--secondary', '--foreground'], 0.65),         opacity: 0.3  },
					{ offset: Math.PI*1.5,  amplitude: 80, frequency: 0.0022, color: resolveColor(['--primary-foreground', '--foreground'], 0.25), opacity: 0.25 },
					{ offset: Math.PI*2,    amplitude: 55, frequency: 0.004,  color: resolveColor(['--foreground'], 0.2),                        opacity: 0.2  },
				],
			};
		}

		let theme = computeColors();

		const observer = new MutationObserver(() => { theme = computeColors(); });
		observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme'] });

		function resize() {
			el.width = window.innerWidth;
			el.height = window.innerHeight;
			center.x = el.width / 2;
			center.y = el.height / 2;
			target.x = el.width / 2;
			target.y = el.height / 2;
		}

		function onMouseMove(e: MouseEvent) { target.x = e.clientX; target.y = e.clientY; }
		function onMouseLeave() { target.x = el.width / 2; target.y = el.height / 2; }

		function drawWave(wave: typeof theme.waves[0]) {
			c.save();
			c.beginPath();
			for (let x = 0; x <= el.width; x += 4) {
				const dx = x - center.x;
				const dy = el.height / 2 - center.y;
				const dist = Math.sqrt(dx * dx + dy * dy);
				const ripple = Math.max(0, 1 - dist / MOUSE_RADIUS) * MOUSE_AMPLITUDE * Math.sin(time * 0.001 + x * 0.01 + wave.offset);
				const y = el.height / 2
					+ Math.sin(x * wave.frequency + time * 0.002 + wave.offset) * wave.amplitude
					+ Math.sin(x * wave.frequency * 0.4 + time * 0.003) * (wave.amplitude * 0.45)
					+ ripple;
				x === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
			}
			c.lineWidth = 2.5;
			c.strokeStyle = wave.color;
			c.globalAlpha = wave.opacity;
			c.shadowBlur = 35;
			c.shadowColor = wave.color;
			c.stroke();
			c.restore();
		}

		function animate() {
			time += 1;
			center.x += (target.x - center.x) * LERP;
			center.y += (target.y - center.y) * LERP;

			const grad = c.createLinearGradient(0, 0, 0, el.height);
			grad.addColorStop(0, theme.bgTop);
			grad.addColorStop(1, theme.bgBottom);
			c.fillStyle = grad;
			c.fillRect(0, 0, el.width, el.height);
			c.globalAlpha = 1;
			c.shadowBlur = 0;
			theme.waves.forEach(drawWave);

			raf = window.requestAnimationFrame(animate);
		}

		resize();
		window.addEventListener('resize', resize);
		window.addEventListener('mousemove', onMouseMove);
		window.addEventListener('mouseleave', onMouseLeave);
		raf = window.requestAnimationFrame(animate);

		return () => {
			window.removeEventListener('resize', resize);
			window.removeEventListener('mousemove', onMouseMove);
			window.removeEventListener('mouseleave', onMouseLeave);
			window.cancelAnimationFrame(raf);
			observer.disconnect();
		};
	});
</script>

<section
	class="relative isolate flex min-h-screen w-full items-center justify-center overflow-hidden bg-background"
	aria-label="Glowing waves hero section"
>
	<canvas bind:this={canvas} class="absolute inset-0 h-full w-full" aria-hidden="true"></canvas>

	<div class="pointer-events-none absolute inset-0 -z-10">
		<div class="absolute left-1/2 top-0 h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-foreground/[0.035] blur-[140px] dark:bg-foreground/[0.06]"></div>
		<div class="absolute bottom-0 right-0 h-[360px] w-[360px] rounded-full bg-foreground/[0.025] blur-[120px] dark:bg-foreground/[0.05]"></div>
		<div class="absolute left-1/4 top-1/2 h-[400px] w-[400px] rounded-full bg-primary/[0.02] blur-[150px] dark:bg-primary/[0.05]"></div>
	</div>

	<div class="relative mx-auto flex w-full max-w-6xl flex-col items-center px-6 py-24 text-center md:px-8 lg:px-12">

		<div
			class="mb-6 inline-flex items-center gap-2 rounded-full border border-border/40 bg-background/60 px-4 py-2 text-xs font-semibold uppercase tracking-[0.25em] text-foreground/70 backdrop-blur transition-all duration-700 dark:border-border/60 dark:bg-background/70 dark:text-foreground/80"
			class:opacity-0={!mounted}
			class:translate-y-6={!mounted}
			style="transition-delay: 0ms"
		>
			<ZapIcon class="h-4 w-4 text-primary" aria-hidden="true" />
			{content.badge}
		</div>

		<h1
			class="mb-6 text-4xl font-semibold tracking-tight text-foreground transition-all duration-700 md:text-6xl lg:text-7xl"
			class:opacity-0={!mounted}
			class:translate-y-6={!mounted}
			style="transition-delay: 120ms"
		>
			{content.heading}{' '}
			<span class="bg-linear-to-r from-primary via-primary/60 to-foreground/80 bg-clip-text text-transparent">
				{content.heading_highlight}
			</span>
		</h1>

		<p
			class="mx-auto mb-10 max-w-3xl text-lg text-foreground/70 transition-all duration-700 md:text-2xl"
			class:opacity-0={!mounted}
			class:translate-y-6={!mounted}
			style="transition-delay: 240ms"
		>
			{content.subtext}
		</p>

		<div
			class="mb-10 flex flex-col items-center justify-center gap-4 transition-all duration-700 sm:flex-row"
			class:opacity-0={!mounted}
			class:translate-y-6={!mounted}
			style="transition-delay: 360ms"
		>
			<Button
				size="lg"
				href={content.primary_cta.href}
				class="group gap-2 rounded-full px-8 text-base uppercase tracking-[0.2em]"
			>
				{content.primary_cta.label}
				<ArrowRightIcon class="h-4 w-4 transition-transform group-hover:translate-x-1" aria-hidden="true" />
			</Button>
			<Button
				size="lg"
				variant="outline"
				href={content.secondary_cta.href}
				class="rounded-full border-border/40 bg-background/60 px-8 text-base text-foreground/80 backdrop-blur transition-all hover:border-border/60 hover:bg-background/70 dark:border-border/50 dark:bg-background/40 dark:text-foreground/70 dark:hover:border-border/70 dark:hover:bg-background/50"
			>
				{content.secondary_cta.label}
			</Button>
		</div>

		<ul
			class="mb-12 flex flex-wrap items-center justify-center gap-3 text-xs uppercase tracking-[0.2em] text-foreground/70 transition-all duration-700 dark:text-foreground/80"
			class:opacity-0={!mounted}
			class:translate-y-6={!mounted}
			style="transition-delay: 480ms"
		>
			{#each content.features as feature}
				<li class="rounded-full border border-border/40 bg-background/60 px-4 py-2 backdrop-blur dark:border-border/60 dark:bg-background/70">
					{feature}
				</li>
			{/each}
		</ul>

		<div
			class="grid w-full gap-4 rounded-2xl border border-border/30 bg-background/60 p-6 backdrop-blur-sm transition-all duration-700 dark:border-border/60 dark:bg-background/70 sm:grid-cols-3"
			class:opacity-0={!mounted}
			class:scale-95={!mounted}
			style="transition-delay: 600ms"
		>
			{#each content.stats as stat}
				<div class="space-y-1">
					<div class="text-xs uppercase tracking-[0.3em] text-foreground/50 dark:text-foreground/60">{stat.label}</div>
					<div class="text-3xl font-semibold text-foreground">{stat.value}</div>
				</div>
			{/each}
		</div>

	</div>
</section>