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- Dynamic Resizing: The cursor adjusts its size and position dynamically when hovering over elements specified in the
useDataElementRectproperty. - Scaling on Interaction: Scale transformations can be applied to the cursor when hovering over specified elements using
scaleOnActive. - Animation Control: Smooth animations with customizable
durationandeasingusing the Web Animations API. - Custom Icons: Allows custom rendering inside the cursor element using the
childrensnippet. - State Exposure: Exposes
activeDataValueandisActiveas bindable props to track cursor state in the parent. - Responsive Design: Automatically disables the interactive cursor below a configurable
breakpointor when reduced motion is preferred. - Reduced Motion: Dynamically responds to OS-level reduced motion changes mid-session — no page reload required.
- Hide Native Cursor: Optionally hides the OS cursor inside trigger areas via
hideNativeCursor. - Performance: Animations are throttled to one per frame via
requestAnimationFrame, layout reads are cached, and the module is loaded only once across all instances.
type 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.
};interface 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;
}For 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. |
- RAF throttling —
mousemoveis throttled to one animation call per frame viarequestAnimationFrame, preventing excessive work at 200+ events/sec. - Cached layout reads —
offsetWidth/offsetHeightare read once at init;getBoundingClientRect()is only called when the hovered element changes. - O(1) scale lookup —
scaleOnActiveis converted to aMapat init for constant-time lookups per frame. - Single module import — the core module is loaded once across all component instances on the page via a shared Promise cache.
will-change: transform— applied only on.activeto promote the element to a compositor layer while animating.- Resize/scroll rect invalidation — the cached bounding rect is recalculated on
resizeandscrollsouseDataElementRectpositions remain accurate.
- Reduced Motion: Automatically disabled on mount if the user prefers reduced motion. Also responds to OS-level changes mid-session without a page reload.
- Responsive: Disabled below the configured
breakpoint(default1024px). - Always place
data-interactive-cursor-areaon the parent container of your interactive elements.
Contributions are welcome!
- Fork the repository.
- Create a new branch for your feature or bugfix.
- Commit your changes with clear and descriptive messages.
- Submit a pull request.
This project is licensed under the MIT License.
