The Problem with Most Portfolio Animations
Open any portfolio site and you'll see the same pattern: elements fade in as you scroll. Maybe a slight translateY. Perhaps a staggered delay. It looks nice for about two seconds, then you've seen it all.
The fundamental issue isn't the technique — it's the intent. Most animations are decorative. They don't communicate anything. They're applied uniformly to every element because someone added an Intersection Observer and called it a day.
Animation as Narrative
When I redesigned my portfolio, I started with a question: What story does the transition tell?
Each project switch isn't just a content swap. It's a scene change. The outgoing project's name rises and fades — like a title card leaving the frame. The accent color shifts across the entire viewport. A new ambient visual materializes behind the incoming project name.
The sequence:
/* Phase 1: Exit (0-200ms) */
@keyframes name-exit {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-40px); }
}
/* Phase 2: Color transition (0-400ms) — simultaneous */
:root {
--accent: #3B82F6; /* transitions via JS */
--transition-accent: 400ms ease;
}
/* Phase 3: Enter (200-420ms) — overlaps with color */
@keyframes name-enter {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
Three phases, carefully overlapped. The color starts shifting before the old content has fully left. The new content enters while the color is still settling. This overlap creates a feeling of continuous motion rather than a sequence of discrete steps.
The 200ms Rule
Through experimentation, I found that 200ms is the threshold between "responsive" and "sluggish" for interface transitions:
| Duration | Perception |
|---|---|
| < 100ms | Instant — feels like a state change, not a transition |
| 100-200ms | Snappy — the user sees movement but doesn't wait |
| 200-400ms | Cinematic — the user appreciates the choreography |
| > 400ms | Slow — the user is waiting, not watching |
The sweet spot for my project switching was 400ms total, but no single element takes longer than 220ms. The perceived speed comes from concurrent animations, not faster individual ones.
Ambient Visuals as Context
Each project has a canvas-based ambient visual that hints at what it does:
- Mjolnir (developer tool): Terminal scanlines with a blinking cursor
- Ariadne (dependency graph): Floating nodes connected by edges
- Magistrate (agent ops): Scrolling monospace log lines
- Timbre (AI content): Gentle sine wave oscillations
- Oras (product search): Pulsing material swatches in a grid
These run at opacity: 0.08 — barely visible. They create atmosphere without competing for attention. The key insight: ambient animation should be felt, not seen.
// The canvas opacity is critical
// Too high (>0.15) and it distracts from the project name
// Too low (<0.05) and you lose the atmospheric effect
canvas.style.opacity = '0.08';
Reduced Motion
All of this is invisible to users who prefer reduced motion:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
.ambient-canvas { display: none; }
}
The content is still fully accessible. The animations are enhancement, not requirement.
Principles
After this project, I distilled my approach to motion design into four principles:
- Every animation tells a story. If you can't explain what an animation communicates, remove it.
- Overlap, don't sequence. Concurrent animations feel faster and more natural than sequential ones.
- Ambient beats dramatic. Subtle, continuous motion creates more atmosphere than flashy one-time effects.
- Speed is in the overlap, not the duration. A 400ms transition can feel instant if its phases are interleaved correctly.
Motion isn't about making things move. It's about making the interface feel alive.