Plugin Authoring
Plugins are the primary extension mechanism in Supermouse. The core runtime is intentionally minimal; it simply aggregates input and coordinates an array of plugins.
What is a Plugin?
At its core, a Supermouse plugin is simply a factory function that returns an object containing lifecycle hooks (install, update, destroy, etc.). This functional approach ensures that each plugin instance encapsulates its own state, avoiding cross-contamination between different cursors on the same page.
import type { SupermousePlugin } from '@supermousejs/core';
export const RedDot = (): SupermousePlugin => {
let el: HTMLDivElement | null = null;
return {
name: 'red-dot',
install(app) {
el = document.createElement('div');
el.style.width = '8px';
el.style.height = '8px';
el.style.borderRadius = '50%';
el.style.background = 'red';
el.style.position = 'fixed';
el.style.pointerEvents = 'none';
app.container.appendChild(el);
},
update(app) {
if (!el) return;
const { x, y } = app.state.smooth;
el.style.transform = `translate(${x}px, ${y}px)`;
},
destroy() {
el?.remove();
}
};
}; You can write plugins entirely from scratch as plain objects (like above), or you can use our definePlugin helper which abstracts away mounting and unmounting DOM elements for standard visual plugins.
name field. That value is how the core instance looks up, enables, and disables plugins at runtime. This is especially important for state-driven plugins such as States(), where the configured names must match the registered plugin names exactly.const app = new Supermouse();
app.use(RedDot());
app.use(Ring());
app.use(States({
default: ['red-dot'],
states: {
hover: ['ring']
}
}));
// Plugin names are part of the runtime contract.
// The strings in States() must match the plugin.name values exactly. If a plugin throws during update(), it is removed from the pipeline, its destroy and onDisable hooks are called, and an error is logged. A plugin that throws during install() is rejected entirely and never added.
Scaffolding Plugins
To streamline plugin development, this repository includes an interactive CLI manager. It automatically generates the correct directory structure, package.json, and a boilerplate index.ts with the appropriate TypeScript types.
pnpm run manage
# Follow the interactive prompts to create a new plugin
# OR run the direct command:
# pnpm run create:plugin <plugin-name> The CLI handles symlinking your new plugin into the Playground so you can instantly start testing it. When you're ready to publish, the toolchain is fully compatible with our changeset automated versioning.
Runtime Model
Every frame Supermouse runs a fixed pipeline. Understanding this order prevents jitter and “tearing”.
- Input System — captures events, writes
state.pointer. - Logic Plugins (
priority < 0) — readpointer, modifystate.target. - Core Physics — interpolates
state.smoothtowardtarget. - Visual Plugins (
priority ≥ 0) — readsmooth, render to DOM.
-10). If a logic plugin runs at default 0, it interleaves with visual plugins: some visuals see the old position, some see the new one. The cursor dot snaps correctly while the ring trails for a frame. Always set priority: -10 for position‑affecting logic.Plugin Types
Logic Plugin
Modifies cursor intent (its destination and bounds). They typically run before visual plugins using negative priority to modify state.target. Because they rarely create DOM nodes, they should be written using the raw interface
export const Gravity = (intensity = 5) => ({
name: 'gravity',
priority: -10, // must run before physics
update(app) {
app.state.target.y += intensity;
}
});Visual Plugin
For 90% of visual plugins, you want to create a single DOM element, dynamically style it based on options, and center it on the cursor. The definePlugin helper is the recommended approach. It handles mounting, unmounting, and normalizing static vs reactive options.
export const Dot = () => ({
name: 'dot',
priority: 0, // runs after physics
update(app) {
const { x, y } = app.state.smooth;
// ... apply to DOM element
}
});
// or
export const Dot = () =>
definePlugin({
name: 'dot',
update: (app) => {
const { x, y } = app.state.smooth;
}
});Advanced Patterns
Some cursor effects need to interact with several DOM elements (for example, a highlight that follows the cursor inside different cards) or behave gracefully when the pointer leaves the window. The approaches below are not the only way to solve these problems, but they are lightweight patterns that have proven effective in real plugins.
Working with multiple targets
If your plugin must track the cursor across several containers, you need a way to associate state with each one. Three common strategies are:
- Per‑frame querying – call
querySelectorAllevery frame and use aMapto lazily create state for new containers. This is simple and works well for a handful of elements. - Pre‑created pool – allocate a fixed number of elements in
install()and decide duringupdatewhich ones to show. This avoids DOM creation in the hot loop and is ideal for particle effects like the Sparkles plugin, where a single pool of particles serves the entire page. - Static registration – collect all target elements once in
install()and reuse the same reference. This is appropriate when the DOM structure is known to be stable.
The key insight is to separate what you’re tracking from how you render it. The Sparkles plugin, for example, doesn’t know about individual containers at all; it simply spawns particles along the pointer’s path. In contrast, a spotlight effect typically needs to know which container the cursor is over so it can clamp the highlight correctly. Choose the strategy that matches your data requirements.
Dealing with the pointer leaving the window
When the pointer exits the browser window, Supermouse sets hasReceivedInput to false and resets the smooth position to off‑screen coordinates (-100, -100). If your plugin blindly renders those coordinates, the effect will jump to the top‑left corner of the screen (or container). There are three common ways to avoid that jump:
- Last‑known position – keep a copy of the most recent valid local coordinates. When the cursor is over a container, update them; when it leaves, use that stored value for rendering. This keeps the effect anchored in place as it fades or shrinks.
- Interpolation along the path – if you are generating trailing particles (like Sparkles), you can stop spawning once
hasReceivedInputis false. The existing particles continue their independent fade‑out without needing a fixed anchor. - Instantly hide – for simple cursor‑replacement dots, it’s often acceptable to set
opacity: 0whenhasReceivedInputis false, provided you restore it on the first valid move after re‑entry.
The last‑known position approach is particularly useful for effects that expand or contract based on hover state, because it allows the shrink animation to play while the effect stays at the exact edge point where the cursor left. The Sparkles plugin handles the off‑screen case differently: it still runs its particle lifecycle (fade and movement) even when no new particles are spawned, so the trail naturally dissipates without jumping.
Native Cursor State
Supermouse automatically detects native controls (inputs, textareas, contenteditable) and exposes state.isNative. Plugins should use this to hide custom visuals when the system cursor must take over. The fallback is configurable via ignoreOnNative and ensures accessibility is never sacrificed for style.
Inter‑Plugin Communication
Plugins are isolated: they never import each other and have no direct knowledge of one another. All coordination happens through shared state buses, specifically state.shape and state.interaction. These act as decoupled channels that let logic plugins publish information that visual plugins consume, without any hard coupling.
The state.shape bus
Logic plugins compute element geometry and write it to state.shape; visual plugins react to it. For example, a Stick plugin measures a target and a Ring plugin morphs to match. This bus allows you to swap the visual plugin (e.g. use a Square cursor instead of Ring) without touching the sticky logic.
// Stick (logic, priority -10)
update(app) {
if (isSticky) {
app.state.shape = { width: 80, height: 40, borderRadius: 8 };
} else {
app.state.shape = null;
}
}
// Ring (visual, priority 0)
update(app) {
if (app.state.shape) {
// morph to these dimensions
} else {
// stay circular
}
}The state.interaction bus
The state.interaction object is a reactive dictionary populated automatically by the core input system whenever a hover occurs. It acts as the primary "bus" that broadcasts context about the hovered element to all plugins without any expensive DOM calls.
There are two main ways the input system populates this object:
- CSS Selector Rules: Configured in the Supermouse constructor via
options.rules. These apply a static state object whenever the pointer is over an element matching the CSS selector. - Data Attributes: Elements can override or supply state inline using
data-[prefix]-*attributes (where prefix defaults tosupermouse). Keys are camel-cased and values are coerced automatically.
options.rules. This is incredibly powerful as it allows you to configure global fallback behaviors via CSS selectors and override them for specific DOM nodes via HTML attributes. Inside a plugin's update loop, you simply read from the bus. Because state.interaction is completely flat, you get O(1) cached reads:
// 1. App initialization sets global rules
const app = new Supermouse({
rules: {
'.btn-danger': { color: 'red' }
}
});
// 2. HTML can override rules via data attributes
// <button class="btn-danger" data-supermouse-color="orange">Hover</button>
// 3. Plugin reads the flat interaction state without querying DOM
update(app) {
const color = app.state.interaction.color;
if (color) {
el.style.backgroundColor = color;
}
}Writing Plugins
Plugins can be written as a plain object or with the definePlugin helper. Both produce the same runtime behaviour. Use factories (functions returning the plugin) to avoid cross‑instance state leakage.
definePlugin for a single visual root, but switch to a plain object when you need multiple roots, custom fragments, logic-only behavior, or specialized lifecycle control. Keep the plugin-name contract stable, use state.interaction for hover context, and let normalize resolve user options once instead of branching inside the hot loop.Plain Object Format
Great for logic plugins, experiments, learning, or multi-element visual effects.
See What is a Plugin? for an example of the plain object format.
Packaged Plugin (definePlugin)
Recommended for reusable or published plugins. definePlugin assumes a single visual root. If you need multiple elements or custom mounting, use a plain object. normalize (detailed in the API Reference) unifies static values and reactive getters so you always receive a plain resolved value.
import { definePlugin, dom } from '@supermousejs/utils';
export const RedDot = () =>
definePlugin({
name: 'red-dot',
create: () => {
const el = dom.createCircle(8, 'red');
return el;
},
update: (app, el) => {
const { x, y } = app.state.smooth;
dom.setTransform(el, x, y);
}
});When to use which?
| Use case | Approach |
|---|---|
| Quick experiment | Plain object |
| Learning the lifecycle | Plain object |
| npm package | definePlugin |
| Configurable visual plugin | definePlugin |
| Multi‑root / custom mounting | Plain object |
| Logic‑only plugin | Plain object |
update(), re-creating DOM nodes on enable/disable, and letting States() refer to plugin names that do not match the runtime plugin.name values exactly. If a plugin misbehaves only in production, run doctor() from the browser console to surface the usual configuration mistakes quickly.Performance Best Practices
Plugins run at 60–240 fps on the main thread. Keep these three rules in mind to maintain a smooth framerate.
1. The DOM Firewall
Never read DOM attributes, layouts (getBoundingClientRect), or computed styles inside update(). The input system scrapes and caches metadata in state.interaction. Use it instead.
2. Frame‑Rate Independence
Different displays have different refresh rates. Use the dt (delta time) argument or the provided math helpers (damp, lerp). Never hard‑code per‑frame increments.
// frame‑rate dependent (bad)
x += (target.x - x) * 0.1;
// frame‑rate independent (good)
import { damp } from '@supermousejs/utils';
x = damp(x, target.x, 12, dt);3. Memory Management
Avoid creating objects or arrays every frame. Reuse vectors, do not create DOM elements in update, and prefer CSS transforms over top/left layout changes to keep the rendering on the GPU.
Plugin Publishing
You are free to publish your plugins to npm under your own namespace, such as supermouse-plugin-xyz or @your-scope/supermouse-xyz. The @supermousejs/* scope is strictly reserved for official plugins and the core engine.