Why Svelte
We chose Svelte for a simple reason: true performance.
Unlike React or Vue, which ship a runtime to the browser, Svelte compiles your code down to pure JavaScript. The result:
- 47% less code than the React equivalent
- Zero Virtual DOM — surgical updates to the real DOM
- Simplicity — concise, modern syntax, similar to Vue
- Native TypeScript with no extra configuration
Explicit Reactivity
// Reactive state — clear and predictable
let scrollY = $state(0);
let collapsed = $state(false);
// Derived values — recalculated automatically
let isVisible = $derived(scrollY > 100 && !collapsed);
// Effects with automatic cleanup
$effect(() => {
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
});
Each primitive has a clear purpose. $state for data that changes, $derived for computations, $effect for side effects. No magic, no tricks.
Pattern: Progressive Disclosure
The psychological principle behind our navigation is progressive disclosure — revealing information gradually so as not to overwhelm the user.
Implementation: Collapsible Navbar
// The navbar collapses after scrolling 300px
let collapsed = $state(false);
$effect(() => {
const updateScroll = (scroll: number) => {
scrollY = scroll;
collapsed = scroll > 300; // Progressive disclosure
};
// ...event subscription
});
Why does it work? When the user arrives, they see all the navigation options. Once they start scrolling (a signal that they found something interesting), we reduce distractions by collapsing the menu. This applies the selective attention principle — fewer visible options = greater focus on the content.
The Psychological Effect
According to Cialdini's research on commitment and consistency, once a user takes a micro-action (scrolling), they are more likely to continue in that direction. The collapsed navbar reinforces that decision: "You're already exploring — keep going."
Pattern: Immediate Feedback (Glow Effects)
One of the most important UX principles is immediate feedback. Every user action should have an instant visual response.
The Problem
Static borders are boring. They don't communicate that the site is "alive" or that it responds to the user.
The Solution: Borders that Follow the Cursor
// Global mouse position store
let mouseX = $state(0);
let mouseY = $state(0);
function handleMouseMove(e: MouseEvent) {
pendingX = e.clientX;
pendingY = e.clientY;
// RAF throttling: maximum 60 updates/second
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
mouseX = pendingX;
mouseY = pendingY;
rafId = null;
});
}
The glow follows the cursor, creating a sense of direct connection between the user and the interface. This activates what neuromarketing calls embodied cognition — when the body (mouse movement) connects with the visual, engagement increases.
Optimization: Proximity Culling
function draw() {
// Don't draw if the mouse is far away
const isNear = Math.abs(mouseX - elementX) < 100;
if (!isNear) return; // Saves GPU cycles
// Only draw when the user is nearby
const gradient = ctx.createRadialGradient(/*...*/);
ctx.fill();
}
Smart trade-off: The effect only renders when the user is near the element. This preserves the visual magic without sacrificing performance across the rest of the page.
Pattern: Smooth Scroll and Perceived Quality
Native scrolling is functional but abrupt. Smooth scrolling communicates quality and attention to detail.
The Psychology of Motion
UX research shows that animations with exponential easing (gradual acceleration/deceleration) are perceived as more "natural" and professional. The human brain expects physical objects to have inertia.
// Exponential easing — feels "physical"
const easing = (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t));
lenisInstance = new Lenis({
duration: 1.2,
easing,
smoothWheel: true,
// Critical: don't break position:fixed on mobile
syncTouch: false
});
Reactive Scroll
// Scroll state available globally
let scrollY = $state(0);
let scrollDirection = $state<'up' | 'down'>('down');
lenisInstance.on('scroll', (e) => {
scrollY = e.scroll;
scrollDirection = e.direction > 0 ? 'down' : 'up';
});
Other components can react to scrollDirection. For example, showing a "back to top" button only when the user is scrolling up — anticipating user needs.
Pattern: Server-Side Rendering and Perceived Speed
Perceived speed matters more than actual speed. A site that shows content immediately feels faster than one that shows a spinner, even if the total load time is the same.
Dynamic Blog with No Loading States
// Everything happens on the server
export const load: PageServerLoad = async ({ params }) => {
const post = await getBlogPost(params.slug);
// The client receives complete HTML, not JSON + render
return { post };
};
// Fetching from S3 — no CMS cold starts
export async function loadBlogPostsServer(): Promise<BlogPost[]> {
const command = new GetObjectCommand({
Bucket: S3_BLOG_BUCKET,
Key: 'blog-entries.json'
});
const response = await s3Client.send(command);
return JSON.parse(await response.Body.transformToString()).posts;
}
Result: The user never sees a "Loading..." message. The page appears fully rendered because the server has already done all the work.
The Engagement Principle
In digital marketing, every second of wait time reduces conversion by ~7%. By eliminating loading states:
- We reduce friction — nothing interrupts the reading flow
- We increase credibility — a fast site is perceived as professional
- We improve SEO — Google rewards Core Web Vitals
Pattern: Canvas over CSS for High-Performance Effects
When you need effects that update 60 times per second, CSS isn't enough. Every gradient change forces a browser re-layout.
The Solution: Direct Canvas
function drawGlow(ctx: CanvasRenderingContext2D) {
ctx.clearRect(0, 0, width, height);
const gradient = ctx.createRadialGradient(
mouseX, mouseY, 0, // Center at the cursor
mouseX, mouseY, 150 // 150px radius
);
gradient.addColorStop(0, 'rgba(235, 94, 40, 0.9)');
gradient.addColorStop(0.4, 'rgba(235, 94, 40, 0.4)');
gradient.addColorStop(1, 'rgba(235, 94, 40, 0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
}
Canvas vs CSS:
- CSS: Layout → Paint → Composite (3 phases)
- Canvas: Paint only (1 phase)
Optimization: IntersectionObserver
A common problem with Canvas: requestAnimationFrame loops keep running even when the element is off-screen. On a page with 9+ canvases, this wastes CPU/GPU.
let isVisible = $state(false);
let observer: IntersectionObserver | null = null;
function startLoop() {
if (animationId !== null) return;
animationId = requestAnimationFrame(loop);
}
function stopLoop() {
if (animationId !== null) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
onMount(() => {
observer = new IntersectionObserver(
(entries) => {
isVisible = entries[0].isIntersecting;
if (isVisible && !isMobile) {
startLoop(); // Only animate when visible
} else {
stopLoop(); // Pause when out of the viewport
}
},
{ rootMargin: '100px', threshold: 0 }
);
observer.observe(canvas);
});
The result: Canvases only consume resources when the user can actually see them. The 100px rootMargin ensures the animation starts smoothly before the element fully enters the viewport.
Pattern: Graceful Degradation on Mobile
Effects that work on desktop can destroy the experience on mobile. Limited battery, less powerful GPU, touch interaction instead of mouse.
Detection and Adaptation
The naive approach — adding a resize listener in every component — creates performance problems: 5+ redundant listeners, each causing layout thrashing.
The solution: Centralized Resize Handler
// src/lib/utils/resize.ts
const DEBOUNCE_DELAY = 150;
let subscribers = new Set<ResizeCallback>();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function notifySubscribers(width: number, height: number) {
subscribers.forEach(callback => callback(width, height));
// ScrollTrigger refreshes once, not per component
ScrollTrigger.refresh();
}
export function subscribeResize(callback: ResizeCallback): () => void {
subscribers.add(callback);
attachListener();
// Immediate invocation with current dimensions
callback(window.innerWidth, window.innerHeight);
return () => {
subscribers.delete(callback);
if (subscribers.size === 0) detachListener();
};
}
Now components simply subscribe:
import { subscribeResize, getDeviceTypeFromWidth } from '$lib/utils/resize';
let isMobile = $state(false);
onMount(() => {
const unsubscribe = subscribeResize((width) => {
isMobile = width < 768;
});
return unsubscribe;
});
// Disable expensive effects on mobile
const shouldRenderGlow = $derived(!isMobile && !disabled);
{#if shouldRenderGlow}
<canvas bind:this={canvas} />
{:else}
<!-- Static fallback: a simple border -->
<div class="border-subtle"></div>
{/if}
The principle: On mobile, less is more. A simple, fluid experience beats a "complete" experience that lags.
Benefits of the centralized handler:
- A single event listener for the entire app
- 150ms debounce prevents layout thrashing
- ScrollTrigger.refresh() runs once, not per component
- Lazy initialization: the listener only exists if there are subscribers
Final Architecture
src/
├── lib/
│ ├── actions/
│ │ └── useLenis.svelte.ts # Smooth scroll
│ ├── components/
│ │ ├── GlassNav.svelte # Adaptive navbar
│ │ ├── GlowingBorder.svelte # Canvas effects
│ │ └── BorderCanvas.svelte # Canvas + IntersectionObserver
│ ├── config/
│ │ ├── animations.ts # Animation configuration
│ │ └── animations.spec.ts # Configuration tests
│ ├── utils/
│ │ ├── resize.ts # Centralized resize handler
│ │ ├── resize.spec.ts # Resize handler tests
│ │ ├── gsap.ts # GSAP plugin registration
│ │ └── gsap.spec.ts # GSAP utility tests
│ ├── stores/
│ │ └── mouse.svelte.ts # Global cursor state
│ └── data/
│ └── blog.ts # S3 data layer with cache
└── routes/
├── +layout.svelte # SSR + Lenis init
└── blog/[slug]/
└── +page.server.ts # Server-side data loading
Each piece has a clear purpose:
- Actions: Reusable behaviors (scroll, reveal)
- Components: UI with encapsulated logic
- Config: Centralized configuration (animations, breakpoints)
- Utils: Shared services (resize, GSAP, helpers)
- Stores: Shared state across components
- Data: Data access layer (S3, APIs)
- Tests (
.spec.ts): Co-located with the code they test
GSAP Centralization
Another pattern we implemented: registering GSAP plugins only once.
// src/lib/utils/gsap.ts
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
let initialized = false;
export function initGSAP(): typeof gsap {
if (!initialized && browser) {
gsap.registerPlugin(ScrollTrigger);
initialized = true;
}
return gsap;
}
// Auto-initialization on first import
if (browser) { initGSAP(); }
export { gsap, ScrollTrigger };
Components simply import from the centralized module:
// Before: every component did this
import gsap from 'gsap';
import ScrollTrigger from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger); // Redundant!
// After: one line, no redundancy
import { gsap, ScrollTrigger } from '$lib/utils/gsap';
Impact: We eliminated duplicate registerPlugin() calls across 7 components.
Performance Budget
| Optimization | Before | After | Impact |
|---|---|---|---|
| Canvas animation loops | 9+ concurrent | Visible only | 🔴 High |
| Resize listeners | 5+ per section | 1 centralized | 🟡 Medium |
| ScrollTrigger.refresh() | Multiple per resize | 1 with debounce | 🟡 Medium |
| GSAP plugin registration | Per component | 1 on load | 🟢 Low |
Conclusions
A website's architecture isn't just about technical performance — it's about how it feels to use it.
- Progressive disclosure reduces cognitive load
- Immediate feedback creates emotional connection
- Smooth scroll communicates quality and professionalism
- SSR eliminates friction from the experience
- Graceful degradation respects the user's context
- Smart centralization eliminates redundant work (resize, GSAP, animations)
Want a site that combines solid architecture with real engagement? Let's talk.

