We Built a Modern Animation System That Doesn't Annoy Users
We Built a Modern Animation System That Doesn’t Annoy Users
TL;DR: We built a scroll animation plugin that triggers animations 150px before content enters the viewport, runs 33% faster than typical animations, and respects prefers-reduced-motion. The result: animations that feel natural instead of forced, with zero external dependencies and full accessibility support.
The Problem: Website Animations Are Usually Annoying
We’ve all experienced it: You scroll down a page and content pops in after it’s already visible. Or animations take so long that you’re waiting to read the content. Or every element bounces and slides and it feels like a carnival.
Common animation problems:
- Late triggers: Content visible for 500ms before animation starts
- Slow durations: 600-800ms animations block reading
- Too much motion: Everything bounces, slides, and rotates
- No accessibility: Ignores users who prefer reduced motion
- Heavy dependencies: Requires AOS, GSAP, or other libraries
The result: Users disable JavaScript, scroll past content, or just leave.
The Breakthrough: Trigger Early, Run Fast
The breakthrough came when we analyzed why animations feel “forced”:
Problem 1: Late Trigger
// BEFORE - Content already visible before animating
{
threshold: 0.1, // 10% visible
rootMargin: '0px 0px -50px 0px' // NEGATIVE margin = even later
}
Elements had to scroll 50px PAST the viewport edge, then wait for 10% visibility. By the time the animation started, users had already seen the static content.
Problem 2: Slow Animations
/* BEFORE - Too slow, blocking readability */
.animate-fade-up { animation: fadeUp 0.6s ease-out forwards; }
.animate-blur-in { animation: blurIn 0.8s ease-out forwards; }
Problem 3: Aggressive Movement
/* BEFORE - Too much motion */
transform: translateY(30px); /* Large slide distance */
filter: blur(10px); /* Heavy blur */
rotate(-5deg); /* Noticeable rotation */
The solution: Trigger animations BEFORE content enters the viewport, make them fast, and reduce movement.
The Technical Implementation
1. The Plugin Architecture
We built animations as a runtime plugin—no build-time injection, easy to upgrade:
export class ScrollAnimationsPlugin implements Plugin {
name = 'scroll-animations';
version = '1.0.0';
private observer: IntersectionObserver | null = null;
private animatedElements = new Set<Element>();
async initialize(config: CoreConfig): Promise<void> {
// Respect user preferences
if (this.prefersReducedMotion()) {
this.showAllContentImmediately();
return;
}
// Apply animation attributes to elements
this.applyAnimationsToElements();
// Set up intersection observer for triggering
this.setupIntersectionObserver();
console.log('✨ [ScrollAnimations] Initialized');
}
async cleanup(): Promise<void> {
this.observer?.disconnect();
this.animatedElements.clear();
}
}
2. Early Trigger with Intersection Observer
The key insight: Trigger animations 150px BEFORE content enters the viewport.
private setupIntersectionObserver(): void {
this.observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
this.triggerAnimation(entry.target);
}
});
},
{
threshold: 0.01, // Trigger as soon as 1% visible
rootMargin: '0px 0px 150px 0px' // POSITIVE = 150px BEFORE viewport
}
);
// Observe all animated elements
this.animatedElements.forEach(element => {
this.observer?.observe(element);
});
}
Before: Content enters viewport → waits 50px → animation starts After: Animation starts 150px BEFORE content enters viewport
Result: Animations complete as content becomes visible. Users never see static content waiting to animate.
3. Smart Animation Mapping
Different elements get different animations based on their semantic role:
const DEFAULT_ANIMATIONS: AnimationConfig[] = [
// Hero sections - premium blur-to-focus entrance
{
selector: 'section[id*="hero"] h1, section:first-of-type h1',
animation: 'blur-in',
delay: 0,
priority: 100
},
{
selector: 'section[id*="hero"] p',
animation: 'fade-up',
delay: 50,
priority: 99
},
{
selector: 'section[id*="hero"] .btn',
animation: 'fade-up',
delay: 150,
priority: 97
},
// Section headings - slide with subtle rotation
{
selector: 'section:not(:first-of-type) h2',
animation: 'slide-rotate',
delay: 0,
priority: 90
},
// Cards and grid items - staggered fade
{
selector: '.card, [class*="grid"] > div',
animation: 'fade-up',
stagger: 100,
priority: 60
},
// Images - zoom fade for visual impact
{
selector: 'section:not(:first-of-type) img',
animation: 'zoom-fade',
delay: 100,
priority: 70
},
// Buttons - subtle entrance
{
selector: '.btn:not(section:first-of-type .btn)',
animation: 'fade-up',
delay: 50,
priority: 50
}
];
Key design decisions:
- Hero gets premium treatment: Blur-in effect for headlines
- Staggered cards: Grid items animate sequentially, not all at once
- Buttons are subtle: No bouncing or elastic effects
- Header/footer excluded: Navigation should never animate
4. Fast, Subtle Animations
We reduced durations and movement distances:
/* Animation Keyframes - Optimized for 2025 */
@keyframes fadeUp {
from {
opacity: 0;
transform: translateY(15px); /* Reduced from 30px */
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes blurIn {
from {
opacity: 0;
filter: blur(4px); /* Reduced from 10px */
transform: scale(0.98); /* Subtle scale */
}
to {
opacity: 1;
filter: blur(0);
transform: scale(1);
}
}
@keyframes slideRotate {
from {
opacity: 0;
transform: translateX(-15px) rotate(-2deg); /* Reduced rotation */
}
to {
opacity: 1;
transform: translateX(0) rotate(0);
}
}
@keyframes zoomFade {
from {
opacity: 0;
transform: scale(1.03); /* Subtle zoom */
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Duration Classes - 33% Faster */
.animate-fade-up { animation: fadeUp 0.4s ease-out forwards; }
.animate-blur-in { animation: blurIn 0.5s ease-out forwards; }
.animate-slide-rotate { animation: slideRotate 0.45s ease-out forwards; }
.animate-zoom-fade { animation: zoomFade 0.4s ease-out forwards; }
Before → After:
| Animation | Before | After | Improvement |
|---|---|---|---|
| fade-up | 0.6s | 0.4s | 33% faster |
| blur-in | 0.8s | 0.5s | 38% faster |
| slide-rotate | 0.7s | 0.45s | 36% faster |
| Movement | 30px | 15px | 50% less |
| Blur | 10px | 4px | 60% less |
| Rotation | 5deg | 2deg | 60% less |
5. Smart Stagger Calculation
For grid items, we calculate optimal stagger based on element count:
private applyAnimationsToElements(): void {
const processedElements = new Set<Element>();
for (const config of sortedConfigs) {
const elements = document.querySelectorAll(config.selector);
// Calculate optimal stagger based on element count
let stagger = config.stagger || 0;
if (stagger > 0 && elements.length > 1) {
// Reduce stagger for many elements to avoid long waits
const optimalStagger = Math.min(stagger, 500 / elements.length);
stagger = Math.max(50, optimalStagger); // Min 50ms, max original
}
elements.forEach((element, index) => {
// Skip if already processed (first match wins)
if (processedElements.has(element)) return;
// Calculate delay (base + smart stagger)
const delay = (config.delay || 0) + (stagger > 0 ? index * stagger : 0);
element.setAttribute('data-animate', config.animation);
element.setAttribute('data-animate-delay', delay.toString());
element.classList.add('animate-hidden');
processedElements.add(element);
this.animatedElements.add(element);
});
}
}
Example: 12 cards with 100ms stagger
- Naive: 12 × 100ms = 1,200ms total wait
- Smart: 500ms / 12 = 42ms stagger → 500ms total wait
6. Accessibility: Respecting User Preferences
Users who prefer reduced motion get instant content:
private prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
private showAllContentImmediately(): void {
// Remove all animation classes, show content instantly
document.querySelectorAll('.animate-hidden').forEach(element => {
element.classList.remove('animate-hidden');
element.classList.add('animate-visible');
});
}
Result: Users with motion sensitivity see all content immediately, no animations.
7. Triggering Animations
When an element enters the viewport (150px early), we trigger its animation:
private triggerAnimation(element: Element): void {
const animationType = element.getAttribute('data-animate');
const delay = parseInt(element.getAttribute('data-animate-delay') || '0');
// Stop observing this element
this.observer?.unobserve(element);
// Apply animation after delay
setTimeout(() => {
element.classList.remove('animate-hidden');
element.classList.add(`animate-${animationType}`);
element.classList.add('animate-visible');
}, delay);
}
The Architecture: Plugin vs Build-Time
Old approach (build-time):
AI Generation → Assembly Step → JSDOM Parsing → Inject Animations → Save HTML
New approach (runtime plugin):
AI Generation → Save Clean HTML
↓
User Loads Website → Core Bundle → Animation Plugin → Runtime Injection
Benefits:
| Aspect | Build-Time | Runtime Plugin |
|---|---|---|
| Location | Assembly step | Core plugin |
| Execution | Build-time (JSDOM) | Browser runtime |
| Upgradability | Regenerate all websites | Update core bundle once |
| Modularity | Tightly coupled | Independent plugin |
| Disable | Regenerate website | Meta tag or API |
The Results: Animations That Don’t Annoy
Before optimization:
- Animations triggered 200ms late
- Durations: 600-800ms
- Users complained animations felt “forced”
- Content “popped in” after being visible
After optimization:
- Animations trigger 150px early
- Durations: 400-500ms (33% faster)
- Animations complete as content becomes visible
- Zero complaints about animations
Performance:
- Zero external dependencies
- Uses native Intersection Observer API
- No layout thrashing
- GPU-accelerated transforms only
Accessibility:
- Respects
prefers-reduced-motion - No motion for users who disable it
- Content always accessible
Why This Matters for Web Development
Most animation libraries are overkill:
- AOS: 14KB, requires setup
- GSAP: 60KB, complex API
- Framer Motion: 100KB+, React-only
Our plugin: ~5KB, zero dependencies, works everywhere.
Key Insights
- Trigger early: Animations should complete as content becomes visible, not start after
- Run fast: 400-500ms is enough. Longer feels slow.
- Move less: 15px movement is noticeable. 30px is excessive.
- Respect preferences: Some users can’t handle motion. Support them.
- Be semantic: Hero headlines deserve premium effects. Buttons don’t.
What’s Next
We’re exploring:
- Scroll-linked animations: Elements that animate based on scroll position
- View transitions: Smooth page transitions using View Transitions API
- Reduced motion alternatives: Subtle opacity fades instead of no animation
- Performance monitoring: Track animation frame rates
But the core insight remains: Good animations enhance content. Bad animations block it.
Try it yourself: Visit any WebZum-generated website. Scroll down. Notice how content animates smoothly as it enters—not after. That’s the difference between annoying and delightful.
Building a website? Key takeaway: Trigger animations early, run them fast, and always respect user preferences. The best animations are the ones users don’t consciously notice.
The future of web animations isn’t more effects—it’s smarter timing.