Glowing Waves Hero
HeroFull-screen canvas hero with animated glowing sine waves, mouse-reactive ripples, theme-aware colors, and a staggered entrance animation.
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>