);
const rafIdRef = React.useRef<number | null>(null);
const activeElementsRef = React.useRef<Map<string, HTMLElement>>(new Map());
const tick = React.useCallback(() => {
const s = stateRef.current;
// Smooth the cursor position by pulling 12% closer each frame
s.smoothX = lerp(s.smoothX, s.rawX, 0.12);
s.smoothY = lerp(s.smoothY, s.rawY, 0.12);
const dx = s.rawX - s.lastSpawnX;
const dy = s.rawY - s.lastSpawnY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 140 && containerRef.current?.contains(document.elementFromPoint(s.rawX, s.rawY))) {
spawnTrailElement(s.smoothX, s.smoothY);
s.lastSpawnX = s.rawX;
s.lastSpawnY = s.rawY;
}
pruneExpiredElements();
rafIdRef.current = requestAnimationFrame(tick);
}, []);
// ... event listeners and cleanup logic
};
**Architecture Rationale:** We store coordinates in a single ref object rather than individual refs to reduce closure overhead. The `lerp` factor of `0.12` creates a noticeable but smooth lag. The distance threshold (`140px`) prevents DOM thrashing by ensuring elements only spawn after meaningful cursor displacement.
### Step 2: Strip Generation and Clip-Path Math
Each image is divided into 10 horizontal segments. Instead of animating the entire image, we animate the `clip-path` of each segment independently. This creates the center-out wipe effect.
```typescript
const createStripContainer = (src: string, x: number, y: number): HTMLElement => {
const wrapper = document.createElement('div');
wrapper.style.position = 'absolute';
wrapper.style.left = '0';
wrapper.style.top = '0';
wrapper.style.width = '200px';
wrapper.style.height = '150px';
wrapper.style.pointerEvents = 'none';
wrapper.style.willChange = 'transform';
const stripCount = 10;
const stripHeight = 100 / stripCount;
for (let i = 0; i < stripCount; i++) {
const strip = document.createElement('div');
const topPct = i * stripHeight;
const bottomPct = (i + 1) * stripHeight;
// Initial state: collapsed to center vertical line
strip.style.clipPath = `polygon(50% ${topPct}%, 50% ${topPct}%, 50% ${bottomPct}%, 50% ${bottomPct}%)`;
strip.style.position = 'absolute';
strip.style.top = `${topPct}%`;
strip.style.left = '0';
strip.style.width = '100%';
strip.style.height = `${stripHeight}%`;
strip.style.backgroundColor = '#111';
strip.style.transform = 'translateZ(0)';
strip.style.backfaceVisibility = 'hidden';
const imgLayer = document.createElement('div');
imgLayer.style.width = '100%';
imgLayer.style.height = '100%';
imgLayer.style.backgroundImage = `url(${src})`;
imgLayer.style.backgroundSize = 'cover';
imgLayer.style.backgroundPosition = 'center';
strip.appendChild(imgLayer);
wrapper.appendChild(strip);
}
return wrapper;
};
Architecture Rationale: Using document.createElement avoids the parsing overhead of innerHTML. Each strip is positioned absolutely within a relative wrapper, allowing independent clip-path manipulation without affecting siblings. The initial polygon collapses to a zero-width vertical line at 50%, ready for expansion.
Step 3: GPU-Accelerated Positioning and Staggered Reveal
Once the DOM node is created, we position it at the smoothed coordinates, then immediately schedule the slide and strip expansion.
const spawnTrailElement = (startX: number, startY: number) => {
const id = crypto.randomUUID();
const el = createStripContainer('/assets/preview-01.jpg', startX, startY);
// Initial transform at lerped position
el.style.transform = `translate3d(${startX}px, ${startY}px, 0)`;
el.style.transition = 'transform 900ms cubic-bezier(0.25, 0.46, 0.45, 0.94)';
containerRef.current?.appendChild(el);
activeElementsRef.current.set(id, el);
// Next frame: slide to real cursor position and trigger strip expansion
requestAnimationFrame(() => {
el.style.transform = `translate3d(${stateRef.current.rawX}px, ${stateRef.current.rawY}px, 0)`;
const strips = el.children;
for (let i = 0; i < strips.length; i++) {
const distFromCenter = Math.abs(i - 4.5);
const delay = distFromCenter * 80; // Stagger in ms
setTimeout(() => {
const strip = strips[i] as HTMLElement;
strip.style.transition = 'clip-path 600ms cubic-bezier(0.87, 0, 0.13, 1)';
strip.style.clipPath = 'polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)';
}, delay);
}
});
// Schedule removal
setTimeout(() => {
activeElementsRef.current.delete(id);
el.remove();
}, 1200);
};
Architecture Rationale: translate3d forces GPU compositing. The slide targets the raw cursor position, creating a forward momentum effect. The stagger math Math.abs(i - 4.5) ensures strips 4 and 5 (center) have the lowest delay, expanding first. Edge strips expand last. The browser's CSS engine handles the interpolation, keeping JavaScript idle during the animation.
Step 4: Exit Sequence and Lifecycle Management
When an element expires, we reverse the stagger logic. Edge strips collapse first, center strips collapse last, creating an inward contraction.
const pruneExpiredElements = () => {
// Handled via individual setTimeout in spawnTrailElement
// In production, replace with a priority queue or object pool for GC efficiency
};
For a production-grade exit, we apply a reverse stagger and a subtle opacity dim before removal:
const triggerExitSequence = (el: HTMLElement) => {
const strips = el.children;
for (let i = 0; i < strips.length; i++) {
const distFromEdge = 4.5 - Math.abs(i - 4.5);
const delay = distFromEdge * 20;
setTimeout(() => {
const strip = strips[i] as HTMLElement;
strip.style.transition = 'clip-path 400ms cubic-bezier(0.55, 0.06, 0.68, 0.19)';
strip.style.clipPath = 'polygon(50% 0%, 50% 0%, 50% 100%, 50% 100%)';
}, delay);
}
// Dim the entire wrapper slightly before strips finish collapsing
el.style.transition = 'opacity 500ms ease';
el.style.opacity = '0.2';
};
Architecture Rationale: The exit stagger uses 4.5 - Math.abs(i - 4.5), which inverts the distance metric. Edge strips (index 0 and 9) get the highest delay value, meaning they collapse first. The opacity dim to 0.2 rather than 0 preserves visual depth during the collapse phase, preventing a flat fade-out.
Pitfall Guide
1. Syncing RAF with React State
Explanation: Calling setState inside requestAnimationFrame forces React to schedule a re-render every 16ms. This blocks the main thread, defeats the purpose of native CSS transitions, and causes severe jank.
Fix: Keep all animation coordinates, timers, and DOM references in useRef. Only use React state for mounting/unmounting the container or toggling accessibility modes.
2. Ignoring prefers-reduced-motion
Explanation: Users with vestibular disorders or motion sensitivity experience nausea or disorientation from continuous cursor trails. Browsers expose this preference via media queries, but many implementations only check it on mount.
Fix: Attach a listener to window.matchMedia('(prefers-reduced-motion: reduce)'). Toggle the RAF loop and clear active elements when the preference changes at runtime.
3. DOM Thrashing via innerHTML
Explanation: Building strip containers with template literals and innerHTML forces the browser to parse HTML strings, rebuild the DOM tree, and recalculate layout for every cursor movement. This creates sustained garbage collection pressure.
Fix: Use document.createElement and appendChild. Direct DOM mutation bypasses string parsing and allows the browser to batch layout calculations more efficiently.
4. Missing Compositor Hints
Explanation: Without explicit hints, the browser may promote elements to the GPU only after detecting animation, causing a one-frame delay and visual stutter. Overlapping layers without backface-visibility also trigger unnecessary repaints.
Fix: Apply will-change: transform before animation starts, transform: translateZ(0) to force immediate layer creation, and backface-visibility: hidden on overlapping strips.
5. Stagger Math Inversion Errors
Explanation: Using the same delay calculation for enter and exit sequences causes the animation to feel unnatural. If strips expand and collapse in the same order, the effect loses its directional flow.
Fix: Calculate enter delays using Math.abs(i - centerIndex). Calculate exit delays using centerIndex - Math.abs(i - centerIndex). This guarantees opposite directional flow.
6. Image Flicker on First Spawn
Explanation: Setting backgroundImage on a dynamically created element triggers a network request if the asset isn't cached. The first trail element appears blank or flashes while loading.
Fix: Preload all trail images on component mount using new Image().src = url. This populates the browser cache before any DOM creation occurs.
7. Uncleaned Event Listeners and RAF IDs
Explanation: Failing to cancel requestAnimationFrame or remove mousemove/resize listeners on unmount causes memory leaks and errors when the component tries to update a detached DOM tree.
Fix: Store the RAF ID in a ref. Cancel it in the cleanup function. Remove all event listeners and clear the active elements map during unmount.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Marketing landing page with heavy imagery | Native CSS + RAF | Zero bundle overhead, GPU-accelerated, preserves LCP | Low (development time) |
| Dashboard with frequent data updates | Native CSS + RAF | Prevents main-thread contention with React reconciliation | Low |
| Complex timeline sequencing (multiple chained effects) | GSAP / Framer Motion | Library provides robust timeline control and easing presets | Medium (bundle + learning curve) |
| Mobile-first application | Disable or simplify trail | Touch devices lack cursor semantics; RAF wastes battery | High (UX degradation if forced) |
| Enterprise app with strict a11y compliance | Native CSS + RAF + reduced-motion toggle | Meets WCAG 2.2 motion guidelines without library polyfills | Low |
Configuration Template
export interface TrailConfig {
spawnThreshold: number;
lifespanMs: number;
stripCount: number;
slideDuration: number;
slideEasing: string;
inDuration: number;
inEasing: string;
outDuration: number;
outEasing: string;
staggerInMs: number;
staggerOutMs: number;
maskColor: string;
targetWidth: number;
targetHeight: number;
}
export const defaultTrailConfig: TrailConfig = {
spawnThreshold: 140,
lifespanMs: 1200,
stripCount: 10,
slideDuration: 900,
slideEasing: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)',
inDuration: 600,
inEasing: 'cubic-bezier(0.87, 0, 0.13, 1)',
outDuration: 400,
outEasing: 'cubic-bezier(0.55, 0.06, 0.68, 0.19)',
staggerInMs: 80,
staggerOutMs: 20,
maskColor: '#111111',
targetWidth: 200,
targetHeight: 150,
};
Quick Start Guide
- Initialize the container: Attach a
ref to a positioned parent element that will host the trail wrappers. Ensure it has position: relative or absolute.
- Mount the orchestrator: Call the custom hook or class constructor, passing the container ref and configuration object. The hook will attach
mousemove listeners and start the RAF loop.
- Preload assets: Run a simple cache population routine on mount. Iterate through your image array and instantiate
new Image() for each source.
- Handle cleanup: Return a cleanup function from your effect or component that cancels the RAF ID, removes event listeners, and clears the active elements map.
- Validate accessibility: Test with
prefers-reduced-motion enabled in DevTools. Confirm the loop pauses and existing elements are removed without errors.