chitecture
Instead of scattering animation logic across event listeners, encapsulate motion within a state machine. This ensures predictable behavior, simplifies testing, and guarantees that ARIA attributes are updated in sync with visual changes.
Architecture: The Motion Controller
We will implement a generic MotionController class that manages the lifecycle of an animation (Idle, Entering, Exiting, Settled) and handles accessibility attributes automatically.
TypeScript Implementation:
interface MotionConfig {
duration: number;
easing: string;
onEnter?: (el: HTMLElement) => void;
onExit?: (el: HTMLElement) => void;
}
export class MotionController {
private element: HTMLElement;
private config: MotionConfig;
private currentState: 'idle' | 'entering' | 'exiting' | 'settled';
private animationFrameId: number | null;
constructor(element: HTMLElement, config: Partial<MotionConfig> = {}) {
this.element = element;
this.config = {
duration: 300,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
...config
};
this.currentState = 'idle';
// Respect system preferences
this.checkReducedMotion();
}
private checkReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
public enter(): void {
if (this.currentState === 'entering' || this.currentState === 'settled') return;
this.currentState = 'entering';
this.element.setAttribute('data-motion-state', 'entering');
this.element.setAttribute('aria-hidden', 'false');
this.element.style.display = 'block';
if (this.checkReducedMotion()) {
this.completeEnter();
return;
}
// Force reflow to ensure transition triggers
void this.element.offsetHeight;
this.element.style.transition = `all ${this.config.duration}ms ${this.config.easing}`;
this.element.style.opacity = '1';
this.element.style.transform = 'translateY(0) scale(1)';
this.config.onEnter?.(this.element);
setTimeout(() => {
this.currentState = 'settled';
this.element.setAttribute('data-motion-state', 'settled');
}, this.config.duration);
}
public exit(): void {
if (this.currentState === 'exiting' || this.currentState === 'idle') return;
this.currentState = 'exiting';
this.element.setAttribute('data-motion-state', 'exiting');
if (this.checkReducedMotion()) {
this.completeExit();
return;
}
this.element.style.transition = `all ${this.config.duration}ms ${this.config.easing}`;
this.element.style.opacity = '0';
this.element.style.transform = 'translateY(10px) scale(0.95)';
this.config.onExit?.(this.element);
setTimeout(() => {
this.completeExit();
}, this.config.duration);
}
private completeEnter(): void {
this.currentState = 'settled';
this.element.setAttribute('data-motion-state', 'settled');
this.element.style.transition = 'none';
this.element.style.opacity = '1';
this.element.style.transform = 'none';
}
private completeExit(): void {
this.currentState = 'idle';
this.element.setAttribute('data-motion-state', 'idle');
this.element.setAttribute('aria-hidden', 'true');
this.element.style.display = 'none';
}
}
3. Usage Pattern
This controller abstracts the complexity of timing and accessibility. You simply instantiate it and call methods based on user interaction.
// HTML: <div id="notification" class="notification" aria-hidden="true">...</div>
const notificationEl = document.getElementById('notification') as HTMLElement;
const notifier = new MotionController(notificationEl, {
duration: 250,
easing: 'ease-out'
});
function showNotification() {
notifier.enter();
}
function hideNotification() {
notifier.exit();
}
Pitfall Guide
1. The "Layout Thrashing" Trap
Explanation: Reading a layout property (like offsetHeight) and immediately writing to a layout property (like style.top) inside a loop forces the browser to recalculate layout repeatedly.
Fix: Batch your reads and writes. Read all necessary dimensions first, store them in variables, then apply styles.
Explanation: Using will-change or transform: translateZ(0) on too many elements consumes excessive GPU memory. This can cause the browser to crash on mobile devices with limited RAM.
Fix: Only promote elements that are actively animating. Remove will-change after the animation completes.
3. Ignoring the "Exit" State
Explanation: Developers often animate elements entering the DOM but fail to animate them leaving, or they remove the element from the DOM immediately upon click, cutting off the exit animation.
Fix: Always use a setTimeout or transitionend event listener to remove the element from the DOM only after the exit animation completes.
4. Hardcoded Durations
Explanation: Using a fixed duration (e.g., 500ms) for all animations regardless of distance or complexity feels unnatural. A small slide should be faster than a large modal expansion.
Fix: Use dynamic duration calculations based on distance or content size, or stick to a strict design system scale (e.g., 150ms, 200ms, 300ms).
5. Accessibility Disconnect
Explanation: The visual state changes, but the screen reader is not notified. For example, a menu slides open, but aria-expanded remains false.
Fix: Tie ARIA updates directly to the state machine transitions, not to CSS classes.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Hover/Focus | CSS Transitions | Zero JS overhead, hardware accelerated. | None |
| Complex Choreography | GSAP / Framer Motion | Robust timeline management, easing control. | Bundle size increase (~10-20kb) |
| List Staggering | CSS Variables + JS | Lightweight, performant, easy to customize. | Minimal |
| Page Transitions | View Transitions API | Native browser support, smoothest possible result. | Browser compatibility checks |
Configuration Template
Use this base CSS configuration to enforce performance standards across your project.
/* Base Animation Config */
:root {
--motion-duration-fast: 150ms;
--motion-duration-normal: 250ms;
--motion-duration-slow: 350ms;
--motion-easing-standard: cubic-bezier(0.4, 0, 0.2, 1);
--motion-easing-enter: cubic-bezier(0, 0, 0.2, 1);
--motion-easing-exit: cubic-bezier(0.4, 0, 1, 1);
}
/* Performance Base Class */
.motion-layer {
/* Promote to compositor layer only when needed */
will-change: transform, opacity;
backface-visibility: hidden; /* Prevents flickering */
}
/* Accessibility Override */
@media (prefers-reduced-motion: reduce) {
.motion-layer {
transition: none !important;
animation: none !important;
will-change: auto !important;
}
}
Quick Start Guide
- Define the Trigger: Identify the user action (click, scroll, load) that initiates the motion.
- Select the Property: Determine if
transform or opacity can achieve the visual goal. If yes, proceed. If no, reconsider the design.
- Initialize Controller: Instantiate the
MotionController (or equivalent) on the target element.
- Bind Events: Attach the
enter() and exit() methods to your UI triggers.
- Verify: Toggle the animation while Chrome DevTools is open. Check the "Layers" panel to confirm GPU compositing is active.