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`;
Wing flap frame 1 Wing flap frame 2 Wing flap frame 3 Wing flap frame 4

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):

Rubbing frame 1 Rubbing frame 2 Rubbing frame 3 Rubbing frame 4

Leg rubbing animation (4 frames):

Leg rubbing frame 1 Leg rubbing frame 2 Leg rubbing frame 3 Leg rubbing frame 4

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):

Slurping frame 1 Slurping frame 4 Slurping frame 7 Slurping frame 10 Slurping frame 13 Slurping frame 16 Slurping frame 18
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:

  1. State-driven animations: Different animations for different interaction states
  2. Lightweight cursor hiding: CSS cascade with transparent SVG instead of JavaScript intervals
  3. Performance optimization: 60fps updates with minimal overhead