Advanced

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

Phase 01: Input

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.

Phase 02: Intent

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.

Rule Logic plugins must never touch the DOM. They only mutate state.
Phase 03: Physics

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.

Phase 04: Render

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);
}
Execution Order Note
Plugins are installed in insertion order (the order .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.

Snippet
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.

Compound Selectors Warning
If you register a compound selector like [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:

  1. Custom resolveInteraction() function: User-provided custom logic.
  2. Per-element data-[prefix]-* attributes: HTML overrides on specific nodes.
  3. 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")
    };
  }
});

State & Plugin Coordination

Plugins are isolated by design. They do not import or call methods on each other. Instead, they coordinate entirely by reading and writing to the central MouseState object.

The Shape State Contract

state.shape is a read-mostly cache for the current cursor geometry (width, height, borderRadius). It is intended to be:

  • Written by one logic plugin per context (e.g., a "stick" plugin that computes the element's rect) early in the pipeline.
  • Read by visual plugins (ring, morph) to avoid redundant getBoundingClientRect calls.
  • Set back to null when the condition that produced the shape ends.
Coordination Warning
Because plugins run in isolation, if multiple logic plugins attempt to write to state.shape simultaneously, the one with the higher priority will overwrite the other, leading to visual flickering or "tearing". To avoid conflicts, check if (!app.state.shape) before writing from a lower-priority plugin.

Velocity & Angle Semantics

It is a common misconception that state.velocity represents the true physical speed of the user's mouse. It does not. As seen in the loop pipeline above, velocity is derived directly from the mathematical difference between the target and the smooth position. state.angle is the direction (in degrees) of that same tracking error vector, computed via atan2(velocity.y, velocity.x).

velocity.x = target.x - smooth.x
velocity.y = target.y - smooth.y
angle = atan2(velocity.y, velocity.x) * (180 / PI)

This makes velocity a vector representing the spring tracking error (or distance to destination). This is highly useful for squishy, organic animations where you want the cursor to stretch based on how far behind it is.

If your plugin specifically requires the raw pixel delta per frame (true mouse speed), you must track it manually against the un-smoothed state.pointer:

Snippet
// Inside your plugin
let lastPointer = { x: 0, y: 0 };

update(app) {
  // Calculate actual pixel movement per frame
  const dx = app.state.pointer.x - lastPointer.x;
  const dy = app.state.pointer.y - lastPointer.y;

  lastPointer = { ...app.state.pointer };
}

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:

Snippet
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) => number

Lifecycle & Error Handling

Native Cursor Visibility Control

Three layers control whether the real cursor is shown or hidden:

  1. Global options.hideCursor: If false, the native cursor is never hidden.
  2. Automatic detection: Based on state.isNative (e.g. hovering over native text inputs).
  3. Forced override: Using app.setNativeCursor("hide" | "show" | "auto") to bypass automatic logic.
CSS Scoping Mechanism
When hiding the cursor automatically, Supermouse injects 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 call app.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.

Note
The destroy() lifecycle hook is not wrapped in a try‑catch. Plugin authors should ensure their cleanup logic is defensive to avoid unhandled exceptions.
supermouse
js
| Copyright © 2024-2026 Stud.io Inc.