Skip to main content
·7 min read

The Architecture of a Blog That Feels Alive

web-devarchitectureperformanceinteractive-design
Blueprint-style diagram of an interconnected blog system with dynamic features, Easter eggs, and AI components
TL;DR

Our blog has 8+ interactive systems running simultaneously — fleeing cards, persona cycling, mood filtering, article rivalry, puzzle fragments, AI companion, and more. Here's how the architecture keeps it fast, maintainable, and genuinely fun to browse.

More Than a Blog — A Living System

Interactive web architecture can support dozens of simultaneous features without sacrificing performance when designed with composability and restraint. Most blog platforms are static by design — a content management system spits out HTML, a CSS framework makes it look acceptable, and maybe a JavaScript widget handles comments. The "interactivity" is a hamburger menu and a dark mode toggle.

Our blog runs 8+ interactive systems simultaneously. Cards flee from your cursor. AI personas cycle every 7 seconds. Articles argue with each other via speech bubbles. A mood filter dims non-matching content. A hidden puzzle spans the entire site. A floating AI companion reacts to your scroll position. And there are surprises hiding in places you wouldn't expect.

This article explains how all of it works together without turning the site into a laggy mess.

The System Map

Here's everything running on the blog at once:

SystemComponentState TypeUpdate Frequency
Fleeing cardsFleeingBlogGriduseRef + useStateOn hover
Persona cyclingFleeingBlogGriduseState + setIntervalEvery 7s
Mood filteringFleeingBlogGriduseStateOn click
Article rivalryFleeingBlogGrid + BlogCarduseState + setTimeoutEvery 25s
Puzzle fragmentsPuzzleProvider (Context)Context + localStorageOn discovery
Lock persistenceFleeingBlogGriduseState + localStorageOn toggle
AI companionReadingCompanionuseState + scroll listenerOn scroll
Card interactionBlogCarduseState + mousemoveOn hover

Each system is independent. None of them share state with others (except the puzzle context, which is read-only from components). This isolation is the single most important architectural decision — it makes the system composable and debuggable.

State Management: Refs vs. State vs. Context

The biggest performance trap in interactive React apps is unnecessary re-renders. Here's how we chose the right state mechanism for each system:

useRef for hot-path data — The slip map (which card is in which grid slot) uses useRef instead of useState. The flee logic runs on mouseover and needs to read/write slot positions without triggering React reconciliation. A ref update is synchronous and render-free.

useState for visual updates — Persona text, mood filter selection, and rivalry bubbles use useState because they need to trigger re-renders. The key is keeping these updates infrequent (7s, 25s, on-click) rather than per-frame.

Context for cross-component shared state — The puzzle system uses React Context because puzzle progress needs to be readable from any component on any page. But it's read-heavy and write-rare (only when a fragment is discovered), so the rendering cost is negligible.

localStorage for persistence — Lock state, companion minimize preference, and puzzle progress all persist across sessions via localStorage. Every read is wrapped in try-catch (because localStorage can throw in private browsing or when quota is exceeded), and every write is fire-and-forget.

The rule: use the weakest tool that solves the problem. If you don't need a re-render, don't use useState. If you don't need cross-component access, don't use Context.

Animation Strategy: CSS > JavaScript

Every animation on the site uses CSS transforms and opacity — properties that the browser can GPU-composite without triggering layout recalculation.

Card landing animationcubic-bezier(0.34, 1.56, 0.64, 1) creates a bouncy overshoot effect. The card scales from 0.85 to 1.03 to 1.0, giving physical weight to the repositioning.

Persona bubble fade-intranslateY combined with opacity creates a slide-up-and-appear effect in 0.5s. The key prop on the bubble forces React to remount it on text change, re-triggering the CSS animation.

Rivalry bubble pop — A more aggressive scale(0.7) → scale(1.08) → scale(1) animation in 0.35s with the same bouncy bezier. The exaggerated scale gives the speech bubbles a "popping into existence" feel.

Companion comment transitiontranslateX(6px) to translateX(0) with opacity. Subtle, because the companion should support reading, not interrupt it.

Zero JavaScript-driven animations. Zero requestAnimationFrame for UI elements. This is how you keep Core Web Vitals clean while running complex interactions.

The Event Model: Passive Listeners and Cleanup

Interactive features mean event listeners. Event listeners mean potential memory leaks and performance issues.

Scroll tracking (companion) — Uses { passive: true } to allow the browser to scroll without waiting for our JavaScript. The handler only does math (division, comparison) — no DOM reads, no layout triggers.

Mouse tracking (cards)onMouseMove on the card element calculates tilt angle and flashlight position. The math uses the element's getBoundingClientRect() (cached in the event handler, not called per-frame) and sets state for CSS custom properties.

Timer cleanup — Every setInterval and setTimeout returns a cleanup function in the useEffect return. The rivalry system stores all timeout IDs in a ref array and clears them on unmount. This prevents the classic "setState on unmounted component" warning.

Ref stability — Callbacks that reference changing state (like the lock toggle or flee handler) use useRef mirrors to avoid stale closures in event handlers. The ref always has the current value; the callback captures the ref, not the value.

Performance Budget

We set a hard budget: all interactive features combined must add less than 15KB of JavaScript (gzipped). Here's the actual breakdown:

FeatureJS Size (approx)
FleeingBlogGrid (flee + personas + mood + rivalry)~5KB
BlogCard (tilt + flashlight + puzzle)~3KB
PuzzleProvider (context + localStorage)~1KB
ReadingCompanion (scroll + comments)~2KB
Other interactive features~8KB

Total interactive JS: ~19KB uncompressed, ~7KB gzipped. Well under budget.

For comparison, a single analytics script typically adds 30-50KB. Our entire interactive layer weighs less than a tracking pixel.

Data Flow: Frontmatter as Configuration

A key architectural decision was using MDX frontmatter as the configuration layer for interactive features:

mood: "spicy"
persona: "\"I built this. You're welcome.\""
companionComments:
  - at: 0.05
    text: "Oh, this one's personal."

This means:

  • No API calls for companion comments or mood data
  • No build-time generation — frontmatter is parsed at request time by Next.js
  • Content authors control the interactive experience — adding companion comments is as easy as editing YAML
  • Zero runtime cost — data flows from MDX → props → components

The IDE advantage of having all context in one workspace applies here too — every interactive feature's configuration lives in the same file as the content it enhances.

What We'd Do Differently

More granular puzzle state — the current step-based system works but doesn't support non-linear discovery. If we rebuild, we'd use a set-based fragment tracker.

Rivalry script generation — 18 hand-written scripts is manageable for 4 posts but won't scale to 20. We'd consider a template system or AI-generated scripts reviewed by a human.

Scroll-based companion triggers — percentage-based triggers work for consistent article lengths but drift on very short or very long posts. Section-based triggers (tied to heading positions) would be more precise.

The Principle: Composable Independence

The architecture works because each system is:

  1. Independent — no shared mutable state between systems
  2. Self-cleaning — every effect cleans up on unmount
  3. Performance-aware — refs for hot paths, state for renders, CSS for animation
  4. Configuration-driven — frontmatter controls behavior, not code changes

If you're building an interactive blog, start with one system. Get it right. Then add another. The architecture should let you add features without touching existing ones — and remove features without breaking anything. For a high-level tour of every interactive system and what it does, start with our guide to building interactive web components.

That's what "feels alive" actually means: not one big feature, but many small ones that don't know about each other, yet create something greater than the sum.