building an animated custom cursor in angular
Replace the default cursor with a playful animated fly
· by Aron Kovacs
the concept
The default cursor is boring. Why not replace it with something more interesting? In this article, I'll walk through building a custom animated cursor component that replaces the system cursor with an animated fly character that responds to different user interactions.
component setup
The custom cursor is a standalone Angular component that needs to:
- Track mouse position in real-time
- Display different animations based on cursor state
- Hide the system cursor completely
- Handle edge cases like mobile devices and screen recording
Start by creating a standalone component with the necessary imports:
import { Component, OnInit, OnDestroy, HostListener, Renderer2, Inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DOCUMENT } from '@angular/common';
the three animation states
The cursor has three distinct animation states, each with its own sprite frames:
1. Moving (Wing Flapping): When the mouse is moving, the fly shows a 4-frame wing flapping animation. This creates a lively, buzzing effect.
// Use wing flap animation when moving (frames 00-03)
return `/assets/img/cursor/fly_wing_flap_frame_${frameNumber}.png`;
2. Idle (Rubbing): After 500ms of no movement, the fly switches to an idle "rubbing" animation. It alternates between two different rubbing animations (regular rubbing and leg rubbing) to add variety.
// Alternates between regular and leg rubbing
if (this.useLegRubbing) {
return `/assets/img/cursor/fly_leg_rubbing_v2_frame_${this.currentFrame - 1}.png`;
} else {
return `/assets/img/cursor/fly_rubbing_frame_${frameNumber}.png`;
}
Regular rubbing animation (4 frames):
Leg rubbing animation (4 frames):
3. Over Clickable Elements (Slurping): When hovering over buttons, links, or other interactive elements, the fly shows an 18-frame "slurping" animation.
// Use slurping animation when over clickable elements (frames 00-17)
return `/assets/img/cursor/fly_slurping_v5_frame_${frameNumber}.png`;
Slurping animation (showing key frames from 18 total):
tracking mouse position
The cursor needs to follow the mouse smoothly. Use Angular's @HostListener to capture mouse events:
@HostListener('document:mousemove', ['$event'])
onMouseMove(event: MouseEvent) {
this.cursorX = event.clientX;
this.cursorY = event.clientY;
this.isCursorVisible = true;
this.isMoving = true;
this.resetMovementTimer();
}
Update the cursor position at 60fps for smooth movement:
setInterval(() => {
this.updateCursorPosition();
}, 16); // ~60fps update rate
detecting clickable elements
To trigger the slurping animation, detect when the cursor hovers over interactive elements:
private isClickableElement(element: HTMLElement): boolean {
const clickableSelectors = [
'button', 'a', '[role="button"]',
'.icon-holder', 'input', 'textarea', 'select'
];
return clickableSelectors.some(selector =>
element.matches(selector) || element.closest(selector)
);
}
hiding the system cursor (the lightweight approach)
The trickiest part is completely hiding the default cursor. Initially, I tried aggressive JavaScript approaches with intervals and MutationObservers, but that was overkill. The solution? A lightweight CSS-based approach using a transparent SVG cursor.
The transparent cursor trick
Instead of cursor: none (which some browsers override for accessibility), use a 1x1 transparent SVG as the cursor:
cursor: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1"><rect width="1" height="1" fill="transparent"/></svg>') 0 0, none !important;
Global CSS application
Apply this to all elements in your index.html using CSS cascade:
*, *::before, *::after { cursor: url(...) !important; }
*:hover, *:focus, *:active { cursor: url(...) !important; }
Lightweight JavaScript backup
Add a small script that only runs on page load (not continuously):
function hideCursor() {
document.documentElement.style.cursor = transparentCursor;
document.body.style.cursor = transparentCursor;
}
window.addEventListener('load', hideCursor);
The key insight: Let CSS cascade do the heavy lifting instead of JavaScript intervals. Set the cursor on root elements and use !important to cascade down.
mobile responsiveness
Custom cursors don't make sense on mobile devices. Hide the custom cursor and restore normal behavior on smaller screens:
@media (max-width: 768px) {
.custom-cursor { display: none !important; }
html, body, * { cursor: auto !important; }
}
performance considerations
A few tips to keep the custom cursor performant:
- CSS over JavaScript: Use CSS cascade with !important instead of JavaScript intervals for cursor hiding
- Use fixed positioning: Position the cursor as fixed with high z-index (9999) to ensure it's always on top
- Disable pointer events: Set pointer-events: none on the cursor element so it doesn't interfere with clicks
- Optimize animations: Use steps(1) animation for sprite-based animations instead of smooth transitions
- Update at 60fps: Match browser refresh rate for smooth tracking
frame management
Each animation state has a different number of frames. Reset the frame counter when switching between states to avoid jarring transitions:
// Reset frame if switching between different animation states
if (wasOverClickable !== this.isOverClickable) {
this.currentFrame = 1;
}
the final touch
Handle edge cases for a polished experience:
- Hide cursor when mouse leaves the page (mouseenter/mouseleave events)
- Show cursor immediately when mouse moves
- Clean up intervals and timers in ngOnDestroy to prevent memory leaks
Pro tip: The transparent SVG cursor trick works where cursor: none fails. It's the secret to reliable cross-browser cursor hiding without performance-heavy JavaScript.
key takeaways
Building a custom cursor requires careful attention to state management, animation timing, and smart cursor suppression. The result is a delightful UX detail that adds personality to your application.
The three core principles:
- State-driven animations: Different animations for different interaction states
- Lightweight cursor hiding: CSS cascade with transparent SVG instead of JavaScript intervals
- Performance optimization: 60fps updates with minimal overhead