A small, dependency-free TypeScript library for an animated cursor orb with hover feedback, velocity stretch, click effects, magnetic movement, and contextual labels.
- Smooth
requestAnimationFramepointer following - Automatic hover feedback for common interactive elements
- Velocity-based motion stretch
- Click pulse and expanding ripple
- Optional magnetic movement with no layout measurements while disabled
- Labels from
data-cursor-orb-label - Hidden zones through
data-cursor-orb-hidden - CSS custom properties and DOM state attributes
- Fine-pointer and reduced-motion safeguards
- Runtime updates and complete lifecycle cleanup
- ESM, CommonJS, and TypeScript declarations
- Zero runtime dependencies
pnpm add cursor-orbnpm install cursor-orbyarn add cursor-orb<script type="module">
import CursorOrb from "https://cdn.jsdelivr.net/npm/cursor-orb/dist/index.js";
const orb = new CursorOrb();
</script>import CursorOrb from "cursor-orb";
const orb = new CursorOrb();The default instance uses:
- 16 px diameter
- 1 px solid
#6459d7border - transparent fill
0.15movement speed1.5automatic interactive hover scale- click pulse and ripple
- velocity stretch
- labels when a labelled element is present
- magnetic movement disabled
The orb starts hidden and becomes visible after the first pointer movement.
Simple appearance and lifecycle values remain top-level. Related behavior is grouped into hover, click, motionStretch, magnetic, and label.
import { CursorOrb } from "cursor-orb";
const orb = new CursorOrb({
size: 18,
borderWidth: 2,
borderColor: "#6459d7",
borderStyle: "solid",
fillColor: "rgba(100, 89, 215, 0.12)",
opacity: 0.95,
speed: 0.15,
zIndex: 10000,
transitionDuration: 180,
hover: {
scale: 1.6,
},
click: {
pulse: true,
duration: 480,
ripple: true,
rippleColor: "#6459d7",
rippleScale: 3,
rippleDuration: 620,
},
motionStretch: {
strength: 0.6,
maxScale: 1.65,
},
magnetic: {
strength: 0.18,
},
label: {
fontSize: 12,
color: "#ffffff",
fontFamily: "inherit",
fontWeight: 600,
padding: 8,
maxWidth: 180,
fillColor: "#6459d7",
borderColor: "#6459d7",
},
});Every feature group accepts:
- an object to configure it;
trueto use its defaults;falseto disable it completely.
const orb = new CursorOrb({
click: false,
motionStretch: true,
magnetic: false,
label: false,
});| Option | Type | Default | Description |
|---|---|---|---|
size |
number |
16 |
Orb diameter in CSS pixels |
borderWidth |
number |
1 |
Border width in CSS pixels |
borderColor |
string |
#6459d7 |
Any CSS border color |
borderStyle |
string |
solid |
CSS border style |
fillColor |
string |
transparent |
Any CSS fill color |
opacity |
number |
1 |
Visible opacity from 0 to 1 |
speed |
number |
0.15 |
Per-frame interpolation factor greater than 0 and at most 1 |
zIndex |
number |
9999 |
Stacking level |
transitionDuration |
number |
180 |
Opacity, hover, size, and fill transition duration |
className |
string |
"" |
Additional classes for the orb |
interactiveSelector |
string |
"" |
Additional selector for project-specific interactive elements |
respectFinePointer |
boolean |
true |
Require (pointer: fine) |
respectReducedMotion |
boolean |
true |
Disable for reduced-motion users |
reducedMotionBehavior |
"disable" | "static" |
"disable" |
Disable the orb or use instant, non-animated movement when reduced motion is requested |
hideNativeCursor |
boolean |
false |
Hide the document's native cursor while mounted |
autoStart |
boolean |
true |
Start during construction; constructor-only |
allowMultipleInstances |
boolean |
false |
Permit multiple instances for one document; constructor-only |
style |
Partial<CSSStyleDeclaration> |
{} |
Additional visual styles |
document |
Document |
global document |
Document used for iframes or isolated DOM contexts |
Hover feedback is automatic. No selector configuration is required.
CursorOrb recognizes:
- links;
- enabled buttons and form controls;
summary;- elements with link or button roles;
- non-negative
tabindexvalues; - draggable elements;
- editable elements;
- elements carrying
data-cursor-orb-label.
Custom controls can be included with interactiveSelector:
const orb = new CursorOrb({
interactiveSelector: "[data-action], .drag-handle",
});const orb = new CursorOrb({
hover: {
scale: 1.8,
},
});| Hover option | Default | Description |
|---|---|---|
scale |
1.5 |
Orb scale over an interactive element |
Disable hover scaling:
orb.update({ hover: false });Hidden zones
Add data-cursor-orb-hidden to an element or any container:
<div data-cursor-orb-hidden>
The orb is hidden over this entire subtree.
</div>The orb is also hidden over iframe and video elements by default. A hidden zone takes priority over hover, labels, magnetic movement, and click effects.
Click feedback contains two independent effects:
- a pulse on the main orb;
- an expanding ripple at the exact pointer position.
const orb = new CursorOrb({
click: {
pulse: true,
duration: 480,
ripple: true,
rippleColor: "#6459d7",
rippleScale: 3,
rippleDuration: 620,
},
});| Click option | Default | Description |
|---|---|---|
pulse |
true |
Contract, expand, and settle the main orb |
duration |
480 |
Pulse duration in milliseconds |
ripple |
true |
Create an expanding click ring |
rippleColor |
#6459d7 |
Ripple border color |
rippleScale |
3 |
Final ripple scale |
rippleDuration |
620 |
Ripple duration in milliseconds |
Ripple elements are temporary, have pointer-events: none, and are removed when their animation finishes. destroy() also removes any active ripple immediately.
Motion stretch deforms the orb along the current movement direction. The deformation responds to pointer velocity and smoothly settles back to a circle.
const orb = new CursorOrb({
motionStretch: {
strength: 0.8,
maxScale: 1.9,
},
});| Motion stretch option | Default | Description |
|---|---|---|
strength |
0.6 |
Velocity response; 0 produces no deformation |
maxScale |
1.65 |
Maximum horizontal stretch from 1 to 4 |
When motionStretch is false, velocity and deformation calculations are skipped.
Magnetic movement pulls the orb toward the center of the current interactive element:
const orb = new CursorOrb({
magnetic: {
strength: 0.2,
},
});| Magnetic option | Default | Description |
|---|---|---|
strength |
0.18 |
Attraction from 0 to 1 |
Magnetic movement is disabled by default and stays aligned during scrolling and layout changes when enabled.
Label text belongs to the markup:
<a href="/projects" data-cursor-orb-label="View">
Projects
</a>
<button type="button" data-cursor-orb-label="Save">
Save changes
</button>Its appearance belongs to the CursorOrb configuration:
const orb = new CursorOrb({
label: {
fontSize: 13,
color: "#ffffff",
fontFamily: "Inter, sans-serif",
fontWeight: 650,
padding: 10,
maxWidth: 200,
fillColor: "#6459d7",
borderColor: "#6459d7",
},
});| Label option | Default | Description |
|---|---|---|
fontSize |
12 |
Text size in CSS pixels |
color |
#ffffff |
Text color |
fontFamily |
inherit |
Text font family |
fontWeight |
600 |
Text font weight |
padding |
8 |
Horizontal and vertical padding |
maxWidth |
180 |
Maximum label width |
fillColor |
#6459d7 |
Orb fill while the label is visible |
borderColor |
#6459d7 |
Orb border while the label is visible |
Label text is inserted with textContent; markup from the attribute is never interpreted as HTML.
While a label is visible, hover scaling and motion stretch are suspended so text remains stable and sharp.
Labels are decorative, so the underlying element should retain an accessible name.
CursorOrb exposes its configuration and live motion state on [data-cursor-orb]:
| Property | Example | Meaning |
|---|---|---|
--cursor-orb-size |
16px |
Base diameter |
--cursor-orb-border-width |
1px |
Border width |
--cursor-orb-border-color |
#6459d7 |
Border color |
--cursor-orb-border-style |
solid |
Border style |
--cursor-orb-fill-color |
transparent |
Base fill |
--cursor-orb-opacity |
1 |
Visible opacity |
--cursor-orb-hover-scale |
1.5 |
Configured hover scale |
--cursor-orb-stretch |
1 |
Current live stretch |
--cursor-orb-angle |
0deg |
Current movement angle |
--cursor-orb-stretch-strength |
0.6 |
Configured stretch strength |
--cursor-orb-magnetic-strength |
0 |
Configured magnetic strength |
--cursor-orb-ripple-color |
#6459d7 |
Configured ripple color |
--cursor-orb-label-font-size |
12px |
Configured label size |
--cursor-orb-label-color |
#ffffff |
Configured label color |
--cursor-orb-label-fill-color |
#6459d7 |
Configured label fill |
The visual element also exposes its current state:
<div data-cursor-orb data-cursor-orb-state="hover"></div>Possible values are:
visible
hover
label
hidden
This makes additional project-specific CSS possible without adding more JavaScript options:
[data-cursor-orb][data-cursor-orb-state="label"] {
box-shadow: 0 8px 24px rgb(100 89 215 / 25%);
}orb.update({
speed: 0.1,
motionStretch: {
strength: 0.9,
},
click: {
rippleColor: "#22c55e",
},
});Feature objects are deeply merged with their existing values. Updating click.rippleColor, for example, preserves the existing pulse and duration settings.
Invalid options throw a descriptive RangeError or TypeError.
The visual HTMLDivElement, or null while CursorOrb is inactive:
orb.element?.classList.add("project-cursor");Whether the instance is currently mounted.
Alias for active.
Whether the instance has been started.
Updates appearance and behavior in place.
Removes the orb, active frames, effects, and listeners. The instance can be restarted.
Restarts a stopped instance and reevaluates pointer and motion preferences.
Permanently cleans up the instance. A destroyed instance cannot be restarted.
CursorOrb separates position, deformation, and visual effects so transforms cannot interfere with each other:
<div data-cursor-orb-positioner aria-hidden="true">
<div data-cursor-orb-deformer>
<div class="cursor-orb" data-cursor-orb aria-hidden="true">
<span data-cursor-orb-label-text></span>
</div>
</div>
</div>An active click ripple temporarily uses:
<div data-cursor-orb-ripple aria-hidden="true"></div>CursorOrb is decorative and keeps the native cursor visible by default. Set
hideNativeCursor: true to use the orb as a visual replacement.
By default it is not mounted when:
(pointer: fine)does not match;(prefers-reduced-motion: reduce)matches.
These preferences are observed while the instance is running.
Use reducedMotionBehavior: "static" to keep an instant, non-animated orb when
reduced motion is requested.
Construct CursorOrb only on the client and destroy it during cleanup.
import { useEffect } from "react";
import CursorOrb from "cursor-orb";
export function App() {
useEffect(() => {
const orb = new CursorOrb();
return () => orb.destroy();
}, []);
return <main>...</main>;
}import { onMounted, onUnmounted } from "vue";
import CursorOrb from "cursor-orb";
let orb: CursorOrb | undefined;
onMounted(() => {
orb = new CursorOrb();
});
onUnmounted(() => {
orb?.destroy();
});pnpm install
pnpm dev
pnpm typecheck
pnpm test
pnpm build
pnpm build:docsFor Netlify, deploy from the repository root. The included netlify.toml builds the demo and publishes docs-dist; the source docs directory must not be deployed directly.
CursorOrb targets modern browsers with matchMedia, Pointer Events, requestAnimationFrame, CSS custom properties, and the Web Animations API.