Halo Cursor is a lightweight, framework‑agnostic animated cursor halo for modern web apps. It follows the pointer, highlights interactive elements, and can completely hide the native cursor – all in plain TypeScript with zero runtime dependencies. (demo)
It works great with:
prefers-reduced-motion and coarse pointers (e.g. touch)hideNativeCursorrootElement, and use pause / resume# npm
npm install @andreasnicolaou/halo-cursor
# yarn
yarn add @andreasnicolaou/halo-cursor
# pnpm
pnpm add @andreasnicolaou/halo-cursor
<!-- unpkg CDN (latest version, unminified) -->
<script src="https://unpkg.com/@andreasnicolaou/halo-cursor/dist/index.umd.js"></script>
<!-- unpkg CDN (latest version, minified) -->
<script src="https://unpkg.com/@andreasnicolaou/halo-cursor/dist/index.umd.min.js"></script>
<!-- jsDelivr CDN (unminified) -->
<script src="https://cdn.jsdelivr.net/npm/@andreasnicolaou/halo-cursor/dist/index.umd.js"></script>
<!-- jsDelivr CDN (minified) -->
<script src="https://cdn.jsdelivr.net/npm/@andreasnicolaou/halo-cursor/dist/index.umd.min.js"></script>
UMD (global halo variable):
<script src="https://unpkg.com/@andreasnicolaou/halo-cursor/dist/index.umd.min.js"></script>
<script>
// window.halo is the UMD global
const cursor = new halo.Cursor({
hideNativeCursor: true,
});
cursor.mount();
// later: cursor.destroy();
</script>
import { Cursor } from '@andreasnicolaou/halo-cursor';
const cursor = new Cursor({
outerSize: 40,
innerSize: 8,
hideNativeCursor: true,
});
cursor.mount();
const { Cursor } = require('@andreasnicolaou/halo-cursor');
const cursor = new Cursor({
hideNativeCursor: true,
});
cursor.mount();
import { Cursor } from '@andreasnicolaou/halo-cursor';
const cursor = new Cursor({
color: '#6366f1',
hoverColor: '#818cf8',
outerSize: 36,
hoverOuterSize: 52,
hideNativeCursor: true,
lerp: 0.12,
});
// Attach to the whole document
cursor.mount();
// Temporarily pause animation/interaction (e.g. while a modal is open)
cursor.pause();
cursor.resume();
// Clean up when leaving the page / unmounting your app
// cursor.destroy();
Cursor methods| Method | Description |
|---|---|
new Cursor(options?) |
Creates a new halo cursor instance with the given options. |
mount() |
Injects styles, creates DOM elements, attaches events, and starts the animation loop. |
destroy() |
Stops the loop, removes event listeners, DOM nodes, and restores the native cursor. |
updateOptions(options) |
Merges new options into the existing ones and reinjects the stylesheet while mounted. |
pause() |
Temporarily pauses tracking and hides the halo off‑screen without destroying the instance. |
resume() |
Resumes tracking and animation after a previous pause(). |
CursorOptions| Property | Type | Default | Description |
|---|---|---|---|
outerSize |
number |
36 |
Size of the outer halo ring in pixels |
innerSize |
number |
6 |
Size of the inner dot in pixels |
hoverOuterSize |
number |
52 |
Outer ring size on hover |
clickOuterSize |
number |
26 |
Outer ring size on click |
color |
string |
'#6366f1' |
Base color of the cursor halo |
hoverColor |
string |
'#818cf8' |
Color on hover state |
outerBorderColor |
string |
'rgba(99, 102, 241, 0.7)' |
Outer ring border color |
hoverBorderColor |
string |
'rgba(129, 140, 248, 1)' |
Outer ring border color on hover |
outerBackground |
string |
'transparent' |
Outer ring background fill |
hoverBackground |
string |
'rgba(99, 102, 241, 0.08)' |
Background fill on hover |
clickBackground |
string |
'rgba(99, 102, 241, 0.18)' |
Background fill on click |
zIndex |
number |
9999 |
CSS z-index for cursor elements |
lerp |
number |
0.12 |
Smoothing factor for cursor movement (0–1, clamped). Higher = snappier, lower = smoother/more lag |
hideNativeCursor |
boolean |
false |
Hide the native browser cursor |
interactiveSelectors |
string |
'a, button, [role="button"], input, textarea, select, label, [tabindex="0"]' |
CSS selectors for interactive elements |
classPrefix |
string |
'halo-cursor' |
CSS class name prefix for generated elements |
disableOnReducedMotion |
boolean |
true |
Disable animations if reduced motion is preferred |
rootElement |
HTMLElement | null |
null |
Root element to mount cursor into |
import { AfterViewInit, Component, ElementRef, OnDestroy, ViewChild } from '@angular/core';
import { Cursor } from '@andreasnicolaou/halo-cursor';
@Component({
selector: 'app-root',
template: ` <div #scope class="app-shell"><ng-content></ng-content></div> `,
})
export class AppComponent implements AfterViewInit, OnDestroy {
@ViewChild('scope', { static: true }) scopeRef!: ElementRef<HTMLDivElement>;
private cursor: Cursor | null = null;
ngAfterViewInit(): void {
this.cursor = new Cursor({ rootElement: this.scopeRef.nativeElement, hideNativeCursor: true });
this.cursor.mount();
}
ngOnDestroy(): void {
this.cursor?.destroy();
}
}
import { useEffect, useRef } from 'react';
import { Cursor } from '@andreasnicolaou/halo-cursor';
export function AppCursorScope() {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!ref.current) return;
const cursor = new Cursor({ rootElement: ref.current, hideNativeCursor: true });
cursor.mount();
return () => cursor.destroy();
}, []);
return <div ref={ref}>{/* your app */}</div>;
}
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { Cursor } from '@andreasnicolaou/halo-cursor';
const scopeRef = ref<HTMLElement | null>(null);
let cursor: Cursor | null = null;
onMounted(() => {
if (!scopeRef.value) return;
cursor = new Cursor({ rootElement: scopeRef.value, hideNativeCursor: true });
cursor.mount();
});
onBeforeUnmount(() => {
cursor?.destroy();
});
Use in your template:
<template>
<div ref="scopeRef">
<!-- your content -->
</div>
</template>
disableOnReducedMotion is true (default), it also disables itself when prefers-reduced-motion: reduce is enabled.mount() / destroy(), keeping the class safe to instantiate in SSR environments.Contributions, ideas, and bug reports are welcome. Feel free to open an issue or PR on the GitHub repository.