feat(layout): add ParticlesBackground component and conditional rendering based on browser environment
CI / build (push) Has been skipped
CI / deploy (push) Successful in 46s

- Introduced the `ParticlesBackground` component to enhance the visual experience in the layout.
- Updated the header rendering logic to conditionally display based on the browser environment, ensuring compatibility with server-side rendering.
This commit is contained in:
eewing
2026-03-06 10:30:58 -06:00
parent 8139790202
commit 08728eca90
2 changed files with 188 additions and 4 deletions
@@ -0,0 +1,176 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
/** Number of particles (tuned for desktop background) */
const PARTICLE_COUNT = 80;
/** Max distance to draw a line between two particles */
const LINE_DISTANCE = 120;
/** Particle base speed */
const SPEED = 0.2;
/** Mouse influence: radius and repulsion strength */
const MOUSE_RADIUS = 200;
const MOUSE_STRENGTH = 0.08;
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
let canvasEl: HTMLCanvasElement | null = $state(null);
let ctx: CanvasRenderingContext2D | null = null;
let particles: Particle[] = [];
let animationId = 0;
let isDesktop = $state(false);
/** Mouse position in viewport coords (updated on mousemove, read in draw) */
let mouseX = 0;
let mouseY = 0;
let mouseActive = false;
function initParticles(width: number, height: number): void {
particles = Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * width,
y: Math.random() * height,
vx: (Math.random() - 0.5) * SPEED,
vy: (Math.random() - 0.5) * SPEED,
radius: Math.random() * 1.2 + 0.6
}));
}
function draw(): void {
if (!ctx || !canvasEl) return;
const cssW = canvasEl.offsetWidth;
const cssH = canvasEl.offsetHeight;
const isDark = document.documentElement.classList.contains('dark');
ctx.clearRect(0, 0, cssW, cssH);
// Particle color: subtle, theme-aware
const dotColor = isDark ? 'rgba(148, 163, 184, 0.35)' : 'rgba(100, 116, 139, 0.28)';
const lineColor = isDark ? 'rgba(148, 163, 184, 0.12)' : 'rgba(100, 116, 139, 0.1)';
// Update and draw particles (coordinates in CSS pixels; ctx is scaled by dpr in resize)
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
// Gentle repulsion from mouse
if (mouseActive) {
const dx = p.x - mouseX;
const dy = p.y - mouseY;
const dist = Math.hypot(dx, dy);
if (dist > 0 && dist < MOUSE_RADIUS) {
const falloff = 1 - dist / MOUSE_RADIUS;
const force = (MOUSE_STRENGTH * falloff) / dist;
p.vx += dx * force;
p.vy += dy * force;
}
}
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > cssW) p.vx *= -1;
if (p.y < 0 || p.y > cssH) p.vy *= -1;
p.x = Math.max(0, Math.min(cssW, p.x));
p.y = Math.max(0, Math.min(cssH, p.y));
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = dotColor;
ctx.fill();
}
// Draw lines between nearby particles
ctx.strokeStyle = lineColor;
ctx.lineWidth = 0.8;
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.hypot(dx, dy);
if (dist < LINE_DISTANCE) {
const alpha = 1 - dist / LINE_DISTANCE;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
}
function tick(): void {
draw();
animationId = requestAnimationFrame(tick);
}
function resize(): void {
if (!canvasEl || !browser) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
const w = canvasEl.offsetWidth;
const h = canvasEl.offsetHeight;
canvasEl.width = w * dpr;
canvasEl.height = h * dpr;
const ctx2 = canvasEl.getContext('2d');
if (ctx2) {
ctx2.scale(dpr, dpr);
ctx = ctx2;
}
initParticles(w, h);
}
function onMouseMove(e: MouseEvent): void {
mouseX = e.clientX;
mouseY = e.clientY;
// Only react when cursor is inside viewport (document mouseleave doesn't fire when leaving window)
mouseActive =
e.clientX >= 0 &&
e.clientY >= 0 &&
e.clientX <= (browser ? window.innerWidth : 0) &&
e.clientY <= (browser ? window.innerHeight : 0);
}
$effect(() => {
if (!browser || !isDesktop || !canvasEl) return;
resize();
ctx = canvasEl.getContext('2d');
if (ctx) tick();
window.addEventListener('resize', resize);
document.addEventListener('mousemove', onMouseMove);
return () => {
cancelAnimationFrame(animationId);
window.removeEventListener('resize', resize);
document.removeEventListener('mousemove', onMouseMove);
};
});
onMount(() => {
if (!browser) return;
const mq = window.matchMedia('(min-width: 1024px)');
isDesktop = mq.matches;
const handler = (e: MediaQueryListEvent) => {
isDesktop = e.matches;
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
});
onDestroy(() => {
cancelAnimationFrame(animationId);
});
</script>
{#if browser && isDesktop}
<div
class="particles-bg fixed inset-0 pointer-events-none z-0 overflow-hidden"
aria-hidden="true"
>
<canvas
bind:this={canvasEl}
class="absolute inset-0 w-full h-full"
style="width: 100%; height: 100%;"
></canvas>
</div>
{/if}
+12 -4
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { browser } from '$app/environment';
import { Toaster } from 'svelte-sonner'; import { Toaster } from 'svelte-sonner';
import './app.css'; import './app.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
@@ -11,6 +12,7 @@
import { Switch } from '$lib/components/ui/switch/index.js'; import { Switch } from '$lib/components/ui/switch/index.js';
import LogOutIcon from '@lucide/svelte/icons/log-out'; import LogOutIcon from '@lucide/svelte/icons/log-out';
import MoonIcon from '@lucide/svelte/icons/moon'; import MoonIcon from '@lucide/svelte/icons/moon';
import ParticlesBackground from '$lib/components/effects/ParticlesBackground.svelte';
let { data, children } = $props(); let { data, children } = $props();
let cleanupRealtime: (() => void) | undefined; let cleanupRealtime: (() => void) | undefined;
@@ -78,8 +80,13 @@
}} }}
/> />
{#if showHeader} {#if browser && showHeader}
<header class="border-border flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4 md:px-6"> <ParticlesBackground />
{/if}
<div class="relative z-[1] flex min-h-screen flex-col">
{#if showHeader}
<header class="border-border flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4 md:px-6">
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger <DropdownMenu.Trigger
class="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring" class="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
@@ -121,6 +128,7 @@
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
</header> </header>
{/if} {/if}
{@render children()} {@render children()}
</div>