Architecture
Supermouse uses a predictable frame loop to maintain its high performance. Understanding how this pipeline separates intent from rendering, and how it handles DOM interactions, is the key to writing effective plugins.
The Render Pipeline & Execution Order
Event Aggregation
The input system captures native DOM pointer events outside of the animation loop. It normalizes coordinates and writes them to state.pointer. The core loop never waits on the DOM; it strictly consumes the latest available pointer coordinates.
Logic Plugins
Plugins with a negative priority (e.g., -10) run first. These are "Logic Plugins". They read the physical state.pointer and modify the state.target destination. For example, a magnetic plugin will override state.target to pull the cursor toward a button.
Interpolation
The core steps in and calculates the physics. It interpolates between the current physical position (state.smooth) and the intended position (state.target) using a frame-rate independent exponential decay algorithm.
Visual Plugins
Plugins with a priority of 0 or higher run last. These are "Visual Plugins" (like Dot, Ring, or Trail). They read the final, interpolated state.smooth coordinates and apply them to DOM elements via CSS transform.
// Simplified internal tick function
function tick(time) {
const dt = time - lastTime;
// 1. Reset target to follow raw pointer
state.target = { ...state.pointer };
// 2. Logic Plugins (priority < 0) modify target
runLogicPlugins(state);
// 3. Physics Damping
state.smooth.x = damp(state.smooth.x, state.target.x, lambda, dt);
state.smooth.y = damp(state.smooth.y, state.target.y, lambda, dt);
// 4. Update internal velocity & angle
state.velocity.x = state.target.x - state.smooth.x;
state.velocity.y = state.target.y - state.smooth.y;
state.angle = Math.atan2(state.velocity.y, state.velocity.x) * (180 / Math.PI);
// 5. Visual Plugins (priority >= 0) render results
runVisualPlugins(state);
}.use() is called), but their update() hooks run sorted by their priority number on every frame. A logic plugin modifying state.target should always have a lower (negative) priority than visual plugins reading state.smooth.The DOM Firewall
The DOM Firewall is a core principle: all DOM‑scraping happens when a hover starts, never inside the animation loop. Calling getBoundingClientRect or reading computed styles during a requestAnimationFrame loop causes layout thrashing. Supermouse avoids this by caching element metadata the moment a hover begins.
Registering Hover Targets
For the core to know when to scrape data, your plugin must declare what CSS selectors it cares about. You do this in your plugin's install method using registerHoverTarget.
export default {
name: 'my-plugin',
install(app) {
// Tells the core to scrape data-my-plugin attributes on hover
// and hides the native cursor for these elements.
app.registerHoverTarget('[data-my-plugin]');
},
update(app) {
if (app.state.interaction['my-plugin']) {
// React to the scraped state
}
}
} Calling registerHoverTarget does two critical things under the hood:
- It adds the selector to the internal list used for
element.closest(selectors)detection to trigger hover states. - It injects a global stylesheet (e.g.,
.supermouse-scope-0 [data-my-plugin] { cursor: none !important; }) to hide the native cursor.
Hover Detection & closest()
Internally, all registered hover selectors are joined into a single string (e.g. "a, button, [data-supermouse-stick]"). This string is passed to element.closest(selectors), which finds the nearest ancestor (including self) that matches any of the selectors.
[data-hover] a, closest() will match an ancestor that satisfies the full rule. It does not match the innermost a alone unless it also satisfies the selector. Prefer simple selectors or register multiple individual selectors.<!-- Register: '[data-hover] a' -->
<div data-hover>
<a href="#">Hover me</a> <!-- ✅ matched: <a> inside [data-hover] -->
</div>
<!-- Register: 'a [data-hover]' (WRONG mental model) -->
<a href="#">
<span data-hover>text</span> <!-- ❗ closest('a [data-hover]') matches the <a>, not the <span> -->
</a>Interaction State Resolution
When the user hovers over a registered element, the engine evaluates properties in the following priority order (highest to lowest), merging them into a single flat object stored at state.interaction:
- Custom
resolveInteraction()function: User-provided custom logic. - Per-element
data-[prefix]-*attributes: HTML overrides on specific nodes. - CSS selector rules: Global mappings defined in
options.rules.
Parsing behavior: The prefix (configured via options.dataPrefix, defaults to "supermouse") is stripped, and keys are camelCased (data-[prefix]-my-key becomes interaction.myKey). Empty attributes like data-[prefix]-stick resolve to boolean true.
new Supermouse({
resolveInteraction(el) {
return {
color: el.style.color,
magnetic: el.hasAttribute("data-magnetic")
};
}
});Utilities & Helpers
ValueOrGetter<T>
Many plugin options accept either a static value or a reactive function (state: MouseState) => T. The core library provides a normalize helper to easily evaluate these inside your update hooks:
import { normalize } from "@supermousejs/utils";
// In your plugin's install/update method:
const size = normalize(options.size, 16); // If options.size is undefined, defaults to 16
// 'size' is now guaranteed to be a resolved number,
// even if options.size was passed as (state) => numberLifecycle & Error Handling
Native Cursor Visibility Control
Three layers control whether the real cursor is shown or hidden:
- Global
options.hideCursor: If false, the native cursor is never hidden. - Automatic detection: Based on
state.isNative(e.g. hovering over native text inputs). - Forced override: Using
app.setNativeCursor("hide" | "show" | "auto")to bypass automatic logic.
cursor: none !important scoped with a generated class (e.g. .supermouse-scope-0). Be careful: do not pass selectors that start with combinators (>, +, ~), as prepending a class to them results in invalid or unintended CSS scoping.// ❌ BAD: selector starts with a combinator
'> .child' → becomes '.supermouse-scope-0 > .child'
// ✅ GOOD: simple selectors only
'.child', '[data-my-plugin]'Execution Flags (enableTouch & autoStart)
enableTouch(default:false): When true, touch events are processed instead of ignored. Experimental.autoStart(default:true): If false, the animation loop will not start until you explicitly callapp.start(). Useful when waiting for additional setup.
Plugin Error Handling
Supermouse wraps plugin execution so that one buggy plugin won't crash the entire cursor loop. If a plugin throws an error in its install() or update() hooks, the core catches it, logs the error to the console, disables the plugin (isEnabled = false), and attempts to safely run its onDisable hook.
destroy() lifecycle hook is not wrapped in a try‑catch. Plugin authors should ensure their cleanup logic is defensive to avoid unhandled exceptions.