MotionConfig {
type: 'css' | 'lottie';
trigger: 'hover' | 'click' | 'mount' | 'programmatic';
payload: string; // CSS class name or JSON URL
container: HTMLElement;
loop?: boolean;
speed?: number;
}
### Step 2: Implement the CSS Renderer
Stylesheet transitions require zero runtime overhead. The implementation simply toggles predefined classes and relies on the browser's compositor thread for execution.
```typescript
class StylesheetMotion implements MotionController {
private element: HTMLElement;
private activeClass: string;
private isAnimating = false;
constructor(config: MotionConfig) {
this.element = config.container;
this.activeClass = config.payload;
}
play(): void {
this.element.classList.add(this.activeClass);
this.isAnimating = true;
}
pause(): void {
this.element.classList.remove(this.activeClass);
this.isAnimating = false;
}
destroy(): void {
this.pause();
}
isPlaying(): boolean {
return this.isAnimating;
}
}
Usage example with a status indicator:
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
background-color: currentColor;
opacity: 0.6;
}
.status-indicator--active {
animation: pulse-ring 1.5s ease-in-out infinite;
}
@keyframes pulse-ring {
0% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.4); opacity: 0.3; }
100% { transform: scale(1); opacity: 0.8; }
}
Step 3: Implement the Lottie Renderer
Vector playback requires dynamic runtime initialization. The wrapper handles lazy loading, frame interpolation, and lifecycle cleanup.
class VectorMotionPlayer implements MotionController {
private instance: any = null;
private container: HTMLElement;
private jsonPath: string;
private loop: boolean;
private speed: number;
constructor(config: MotionConfig) {
this.container = config.container;
this.jsonPath = config.payload;
this.loop = config.loop ?? false;
this.speed = config.speed ?? 1;
}
async play(): Promise<void> {
if (this.instance) return;
const lottie = await import('lottie-web');
this.instance = lottie.loadAnimation({
container: this.container,
renderer: 'svg',
loop: this.loop,
autoplay: true,
path: this.jsonPath,
});
this.instance.setSpeed(this.speed);
}
pause(): void {
this.instance?.pause();
}
destroy(): void {
this.instance?.destroy();
this.instance = null;
}
isPlaying(): boolean {
return this.instance ? !this.instance.isPaused : false;
}
}
Step 4: Build the Routing Dispatcher
The dispatcher evaluates configuration metadata and instantiates the appropriate controller. This layer enables tree-shaking, lazy loading, and centralized performance monitoring.
class MotionEngineRouter {
private controllers = new Map<string, MotionController>();
register(id: string, config: MotionConfig): MotionController {
const controller = config.type === 'css'
? new StylesheetMotion(config)
: new VectorMotionPlayer(config);
this.controllers.set(id, controller);
return controller;
}
execute(id: string, action: 'play' | 'pause' | 'destroy'): void {
const controller = this.controllers.get(id);
if (controller) {
controller[action]();
}
}
cleanup(): void {
this.controllers.forEach(c => c.destroy());
this.controllers.clear();
}
}
Architecture Rationale
The routing pattern exists to solve three production constraints:
- Thread Isolation: CSS transitions execute on the compositor thread, guaranteeing 60fps performance even under main-thread pressure. Lottie operations run on the main thread (or Web Worker if configured), which requires careful scheduling to avoid jank during critical user interactions.
- Bundle Optimization: Importing
lottie-web adds ~100KB to the JavaScript payload. By deferring initialization until a vector animation is explicitly requested, teams can split the runtime into a lazy-loaded chunk, reducing initial load time by 30-40% on motion-heavy pages.
- Designer-Engineering Alignment: After Effects exports produce JSON scene graphs that CSS cannot natively interpret. The router acknowledges this boundary, routing designer-authored assets to the appropriate parser while keeping engineering-owned micro-interactions in stylesheets where they remain version-controlled and easily auditable.
Pitfall Guide
Production motion systems fail when teams ignore execution boundaries or treat animation libraries as drop-in replacements for native browser capabilities. The following mistakes consistently surface in code reviews and performance audits.
1. Main Thread Saturation with Vector Payloads
Explanation: Loading multiple Lottie JSON files on initial render blocks JavaScript execution. Each payload requires parsing, DOM reconstruction, and frame interpolation, which competes with critical hydration tasks.
Fix: Implement IntersectionObserver-based lazy loading. Initialize the vector player only when the container enters the viewport. Preload critical JSON files using <link rel="preload"> with as="fetch" to decouple network latency from execution.
2. CSS Property Triggers Causing Layout Thrashing
Explanation: Animating width, height, top, left, or margin forces the browser to recalculate layout on every frame. This bypasses compositor optimization and causes visible stutter.
Fix: Restrict stylesheet animations to transform and opacity. Use will-change sparingly and only on elements that are actively animating. Remove the hint immediately after the transition completes to free GPU memory.
3. Accessibility Neglect and Vestibular Triggers
Explanation: Continuous or high-velocity motion can trigger vestibular disorders, migraines, or motion sickness. Ignoring user preferences violates WCAG 2.2 guidelines and creates legal liability.
Fix: Wrap all motion initialization in a prefers-reduced-motion check. Provide static fallbacks for vector animations and disable CSS keyframes when the media query matches. Never force autoplay on hero sequences without user consent.
4. Runtime Bloat from Unnecessary Renderers
Explanation: Importing the full lottie-web package when only SVG rendering is required adds canvas, HTML, and worker modules to the bundle. This increases parse time and memory footprint unnecessarily.
Fix: Use tree-shaking friendly imports or switch to lottie-light if only SVG output is needed. Configure bundlers to exclude unused renderer modules. Audit the final bundle with source-map-explorer to verify elimination.
5. State Desynchronization Between Trigger and Renderer
Explanation: CSS class toggles and Lottie play states drift apart when managed in separate component lifecycles. A component may unmount while a vector player continues consuming memory, or a CSS class may remain attached after interaction ends.
Fix: Centralize motion state in a single source of truth. Tie controller lifecycle methods to component mount/unmount hooks. Implement explicit destroy() calls in cleanup functions and verify instance garbage collection in development.
6. Missing Fallbacks for Network Failures
Explanation: Vector JSON files depend on external fetches. Network interruptions, CDN outages, or CORS misconfigurations leave animation containers empty, breaking visual hierarchy.
Fix: Provide static SVG or CSS-based fallbacks that render immediately. Use onError handlers to swap the container content when JSON loading fails. Implement retry logic with exponential backoff for critical motion assets.
7. Over-Engineering Simple Feedback Loops
Explanation: Using a 150KB vector runtime for checkbox toggles, button hovers, or loading spinners introduces unnecessary complexity and performance overhead.
Fix: Establish a complexity threshold. If the animation can be expressed in under 10 lines of CSS using transform or opacity, route it to the stylesheet engine. Reserve vector playback for multi-layer compositions, brand assets, or sequences requiring frame-accurate scrubbing.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Button hover, checkbox toggle, skeleton shimmer | CSS Keyframes | Compositor thread execution, zero JS overhead, instant feedback | 0 KB bundle, negligible CPU |
| Loading spinner, progress indicator, page transition | CSS Keyframes | Predictable timing, native browser optimization, easy state management | 0 KB bundle, minimal memory |
| Onboarding illustration, empty state, success confirmation | Lottie Vector | Multi-layer composition, designer fidelity, programmatic control | ~100 KB runtime + 10-200 KB JSON |
| Animated logo, hero sequence, brand asset | Lottie Vector | Pixel-perfect reproduction, frame-accurate scrubbing, consistent cross-browser rendering | ~100 KB runtime + 10-200 KB JSON |
| Complex data visualization, morphing charts | Lottie Vector | Path interpolation, mask support, After Effects workflow compatibility | ~100 KB runtime + 50-300 KB JSON |
Configuration Template
Copy this TypeScript configuration to establish a production-ready motion routing system with accessibility compliance and lazy loading.
// motion.config.ts
import { MotionEngineRouter, MotionConfig } from './motion-router';
const router = new MotionEngineRouter();
// CSS Motion Registration
const cssConfig: MotionConfig = {
type: 'css',
trigger: 'hover',
payload: 'status-indicator--active',
container: document.querySelector('.status-dot') as HTMLElement,
};
// Lottie Motion Registration (Lazy)
const lottieConfig: MotionConfig = {
type: 'lottie',
trigger: 'programmatic',
payload: '/assets/animations/onboarding-flow.json',
container: document.querySelector('.hero-illustration') as HTMLElement,
loop: false,
speed: 1.2,
};
// Accessibility Gate
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
export function initializeMotion(): void {
if (prefersReducedMotion) {
console.warn('[Motion] Reduced motion preference detected. Disabling non-essential animations.');
return;
}
router.register('status-pulse', cssConfig);
// Lazy initialize Lottie only when container is visible
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
router.register('hero-flow', lottieConfig);
router.execute('hero-flow', 'play');
observer.disconnect();
}
});
}, { threshold: 0.3 });
observer.observe(lottieConfig.container);
}
export { router };
Quick Start Guide
- Install Dependencies: Run
npm install lottie-web and verify your bundler supports dynamic imports for code splitting.
- Create Container Elements: Add placeholder DOM nodes with explicit dimensions for vector animations. CSS transitions require no container setup.
- Register Routes: Use the
MotionEngineRouter to map animation triggers to either CSS classes or JSON payloads. Keep vector registrations lazy-loaded.
- Attach Triggers: Bind hover, click, or mount events to
router.execute() calls. Ensure cleanup handlers call destroy() on component unmount.
- Validate Performance: Open browser DevTools, enable the Performance tab, and verify that CSS animations execute on the compositor thread while vector playback remains under 16ms per frame. Adjust lazy loading thresholds if main-thread jank occurs.