The Architecture of a Blog That Feels Alive
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:
| System | Component | State Type | Update Frequency |
|---|---|---|---|
| Fleeing cards | FleeingBlogGrid | useRef + useState | On hover |
| Persona cycling | FleeingBlogGrid | useState + setInterval | Every 7s |
| Mood filtering | FleeingBlogGrid | useState | On click |
| Article rivalry | FleeingBlogGrid + BlogCard | useState + setTimeout | Every 25s |
| Puzzle fragments | PuzzleProvider (Context) | Context + localStorage | On discovery |
| Lock persistence | FleeingBlogGrid | useState + localStorage | On toggle |
| AI companion | ReadingCompanion | useState + scroll listener | On scroll |
| Card interaction | BlogCard | useState + mousemove | On 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 animation — cubic-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-in — translateY 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 transition — translateX(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:
| Feature | JS 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:
- Independent — no shared mutable state between systems
- Self-cleaning — every effect cleans up on unmount
- Performance-aware — refs for hot paths, state for renders, CSS for animation
- 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.