The InteractiveCursor is a Svelte 5 component that provides a customizable, interactive cursor effect. It dynamically changes its position and size based on user interactions within specified trigger areas. This component is ideal for enhancing user experiences with visually engaging cursor animations.
# npm
npm install @lostisworld/svelte-interactive-cursor
# pnpm
pnpm add @lostisworld/svelte-interactive-cursor
useDataElementRect property.scaleOnActive.duration and easing using the Web Animations API.children snippet.activeDataValue and isActive as bindable props to track cursor state in the parent.breakpoint or when reduced motion is preferred.hideNativeCursor.requestAnimationFrame, layout reads are cached, and the module is loaded only once across all instances.ScaleOnActiveElementtype ScaleOnActiveElement = {
element: string; // The name of the element (value of `data-interactive-cursor`).
scaleMultiplicator?: number; // Scale factor to apply when the element is active. Default: 3.
};
InteractiveCursorOptionsinterface InteractiveCursorOptions {
defaultSize?: number; // Default cursor size in pixels. Default: 32.
scaleOnActive?: ScaleOnActiveElement[]; // Elements with scale factors. Default: [].
duration?: number; // Animation duration in milliseconds. Default: 500.
easing?: string; // CSS easing for the animation. Default: 'linear'.
useDataElementRect?: string[]; // Elements that trigger cursor resizing. Default: [].
hideNativeCursor?: boolean; // Hide the OS cursor inside trigger areas. Default: false.
}
<script lang="ts">
import InteractiveCursor from '@lostisworld/svelte-interactive-cursor';
</script>
<div data-interactive-cursor-area>
<button data-interactive-cursor="btn">Hover me!</button>
</div>
<InteractiveCursor
defaultSize={40}
duration={300}
scaleOnActive={[{ element: 'btn', scaleMultiplicator: 2 }]}
useDataElementRect={['btn']}
/>
<script lang="ts">
import InteractiveCursor, {
type ScaleOnActiveElement,
type ActiveDataValue
} from '@lostisworld/svelte-interactive-cursor';
let currentCursorState: ActiveDataValue = $state({ activeDataName: '', activeDataElement: null });
let cursorIsActive = $state(false);
const scaleOnActive: ScaleOnActiveElement[] = [
{ element: 'image' },
{ element: 'video', scaleMultiplicator: 4 },
{ element: 'link' },
{ element: 'mixblend', scaleMultiplicator: 8 }
];
const customCursorProps = [
{ data: 'image', icon: '<svg>...</svg>' },
{ data: 'video', icon: '<svg>...</svg>', cursorClass: 'bg-red-500 text-white' },
{ data: 'link', icon: '<svg>...</svg>', cursorClass: 'bg-sky-500 text-white' },
{ data: 'tablist', cursorClass: 'rounded-none outline outline-2 outline-purple-500' }
];
</script>
<section data-interactive-cursor-area>
<div data-interactive-cursor="image">Image</div>
<div data-interactive-cursor="video">Video</div>
<div data-interactive-cursor="link">Link</div>
</section>
<InteractiveCursor
bind:activeDataValue={currentCursorState}
bind:isActive={cursorIsActive}
{scaleOnActive}
useDataElementRect={['tablist']}
duration={400}
easing="linear"
breakpoint={1024}
class="rounded-full flex items-center justify-center {currentCursorState.activeDataName === ''
? 'bg-white text-black'
: (customCursorProps.find((s) => s.data === currentCursorState.activeDataName)?.cursorClass ??
'bg-white text-black')}"
>
{#each customCursorProps as { icon, data }}
{#if data === currentCursorState.activeDataName && icon}
{@html icon}
{/if}
{/each}
</InteractiveCursor>
| Prop | Type | Default | Description |
|---|---|---|---|
defaultSize |
number |
32 |
Default cursor size in pixels. |
scaleOnActive |
ScaleOnActiveElement[] |
[] |
Elements and their scale factors when hovered. |
duration |
number |
500 |
Animation duration in milliseconds. |
easing |
string |
'linear' |
CSS easing function for the animation (e.g. 'ease-out', 'cubic-bezier(0.4,0,0.2,1)'). |
useDataElementRect |
string[] |
[] |
Element names for which the cursor resizes and aligns to their bounding rectangle. |
hideNativeCursor |
boolean |
false |
Hides the OS cursor inside trigger areas when true. |
breakpoint |
number |
1024 |
Minimum viewport width (px) below which the cursor is disabled. |
class |
string |
'' |
Additional CSS classes to apply to the cursor element. |
children |
Snippet |
undefined |
Custom content rendered inside the cursor. |
activeDataValue |
ActiveDataValue bindable |
{ activeDataName: '', activeDataElement: null } |
Bindable. Tracks the active data-interactive-cursor name and its DOM element. |
isActive |
boolean bindable |
false |
Bindable. true while the cursor is inside a trigger area. |
| Attribute | Description |
|---|---|
data-interactive-cursor-area |
Marks a container as a cursor tracking zone. Mouse enter/leave is tracked here. |
data-interactive-cursor="value" |
Marks a child element with a name used to match scaleOnActive and useDataElementRect. |
<div data-interactive-cursor-area>
<div data-interactive-cursor="image">Image Element</div>
<div data-interactive-cursor="card">Card Element</div>
</div>
.lw-interactive-cursor — base cursor styles (fixed position, hidden by default)..lw-interactive-cursor.active — applied while the cursor is inside a trigger area.| Variable | Default | Description |
|---|---|---|
--size |
32px |
Driven by defaultSize. |
.lw-interactive-cursor {
background-color: white;
border-radius: 50%;
}
.lw-interactive-cursor.active {
background-color: blue;
}
interactiveCursor functionFor headless / programmatic use, the core function is exported directly:
import { interactiveCursorFN } from '@lostisworld/svelte-interactive-cursor';
const cursor = interactiveCursorFN(cursorElement, {
defaultSize: 32,
scaleOnActive: [{ element: 'btn', scaleMultiplicator: 2 }],
duration: 500,
easing: 'linear',
useDataElementRect: ['card'],
hideNativeCursor: false
});
cursor.init();
// destroy:
cursor.destroy();
| Member | Type | Description |
|---|---|---|
isActive |
boolean (readonly) |
Whether the cursor is inside a trigger area. |
activeDataValue |
ActiveDataValue (readonly) |
Current active element name and reference. |
init() |
() => void |
Attach event listeners and start tracking. |
destroy() |
() => void |
Remove event listeners and cancel animations. |
mousemove is throttled to one animation call per frame via requestAnimationFrame, preventing excessive work at 200+ events/sec.offsetWidth/offsetHeight are read once at init; getBoundingClientRect() is only called when the hovered element changes.scaleOnActive is converted to a Map at init for constant-time lookups per frame.will-change: transform — applied only on .active to promote the element to a compositor layer while animating.resize and scroll so useDataElementRect positions remain accurate.breakpoint (default 1024px).data-interactive-cursor-area on the parent container of your interactive elements.Contributions are welcome!
This project is licensed under the MIT License.