Tips & Tricks
Plugin Architecture & Design
Choose the Right Plugin Template
Use definePlugin helper for standard visual widgets (like circles, icons, or rings) that represent single DOM entities. It manages configuration watches, mounts, style synchronization, and cleanups for you.
export const MyPlugin = (options) =>
definePlugin({
name: 'my-plugin',
create: (app) => { /* return DOM element */ },
update: (app, el) => { /* update element */ }
}, options);Use plain objects for raw performance hooks, plugins with multiple DOM elements, custom canvas contexts, dynamic routing bounds, or logic-only scripts.
export const MyPlugin = (): SupermousePlugin => {
let el = null;
return {
name: 'my-plugin',
install(app) { /* setup */ },
update(app) { /* frame update */ },
destroy() { /* cleanup */ }
};
};Priority Execution Order
Logic plugins must use negative priority (e.g. -10). These write to state.target (directing where the cursor should go) and must run before physics calculations. Using positive or zero priority for positioning updates causes frame lag ("tearing") as rendering occurs before targets stabilize.
definePlugin({
name: 'my-logic',
priority: -10, // ✅ Logic plugins always negative
update(app) {
app.state.target.x += 5;
}
}, options);Visual plugins use zero or positive priority. These hooks run after logic and physics steps to read the interpolated state.smooth coordinates and update styling.
definePlugin({
name: 'my-visual',
priority: 0, // ✅ Visual plugins non-negative
update(app, el) {
const { x, y } = app.state.smooth;
dom.setTransform(el, x, y);
}
}, options);State Management & Inter-Plugin Communication
Share Geometry via state.shape
Avoid redundant DOM rect measurements. Storing bounds in the shared state.shape container allows logic layers (like Stick) to communicate sizes to rendering layers (like Ring) without directly coupling their classes.
Logic Plugin (Stick):
// Calculates geometry, writes to state.shape
app.state.shape = {
width: rect.width + padding,
height: rect.height + padding,
borderRadius: parseFloat(style.borderRadius)
};Visual Plugin (Ring):
// Reads geometry, morphs accordingly
if (app.state.shape) {
el.style.width = `${app.state.shape.width}px`;
el.style.height = `${app.state.shape.height}px`;
} else {
el.style.width = '20px'; // fallback circle
} Use state.interaction instead of DOM Reads
Do not query DOM nodes inside the hot update loop. The input system scrapes attributes from active hovered elements and registers them inside the O(1) state.interaction store, avoiding heavy reflow calculations.
// ❌ WRONG - Causes layout thrashing every frame
update(app) {
const color = app.state.hoverTarget?.getAttribute('data-color');
}// ✅ CORRECT - Pre-cached from mouseover
update(app) {
const color = app.state.interaction.color;
}Define interactive targets in HTML and read them reactively:
export const MyPlugin = (options) => {
return definePlugin({
install(app) {
app.registerHoverTarget('[data-supermouse-myplugin]');
},
update(app) {
const val = app.state.interaction.myplugin;
if (val) {
// Plugin is active for current hover target
}
}
}, options);
};Performance Optimization
1. Adhere to the DOM Firewall
Calling properties like getBoundingClientRect() inside high-frequency frames triggers layout thrashing. Perform measurements inside event listener updates or cache them on hover target changes.
update(app) {
const rect = app.state.hoverTarget?.getBoundingClientRect();
const style = window.getComputedStyle(app.state.hoverTarget!);
}✅ Correct - Cache calculations on target shift:
let cachedRect = null;
update(app) {
if (app.state.hoverTarget && app.state.hoverTarget !== lastTarget) {
lastTarget = app.state.hoverTarget;
cachedRect = dom.projectRect(app.state.hoverTarget, app.container);
}
if (cachedRect) {
// Use cached values
}
}2. Frame-Rate Independent Damping
Ensure velocities and spring timings account for high-refresh screens (e.g. 144Hz). Always use the frame delta time (dt) argument or the math.damp helper.
current += (target - current) * 0.1;✅ Correct - Use the damping utility:
import { math } from '@supermousejs/utils';
const damping = 10;
current = math.damp(current, target, damping, deltaTime);3. Allocation Discipline
Creating objects (`{ x, y }`) inside the render loop triggers garbage collection pauses. Reuse persistent local objects or compute indices inline.
update(app) {
const delta = { x: target.x - current.x, y: target.y - current.y };
}✅ Correct - Inline values or use scalar maths:
update(app) {
const dx = target.x - current.x;
const dy = target.y - current.y;
const dist = math.dist(current.x, current.y, target.x, target.y);
}4. Smart Styling Writes
Use the dom.setStyle helper to skip styling writes unless the property value changes. For bulk initialization inside create/install hooks, use dom.applyStyles.
import { dom } from '@supermousejs/utils';
// Only writes to DOM if value actually changed
dom.setStyle(el, 'width', `${size}px`);
dom.setStyle(el, 'borderColor', color);
// Bulk initialization in install() or create()
dom.applyStyles(el, {
position: 'fixed',
pointerEvents: 'none',
zIndex: Layers.CURSOR
});5. Prefer CSS GPU Transforms
Never mutate CPU-bound properties like `top/left` inside updates. Use dom.setTransform to center elements via translate3d, offloading rendering to the GPU.
// ❌ WRONG - CPU bound
el.style.left = x + 'px';
el.style.top = y + 'px';
// ✅ CORRECT - GPU bound
dom.setTransform(el, x, y); // translate3d + centered automaticallyPlugin Composition Patterns
Stacking Compositions
Layer multiple single-purpose plugins to construct compound cursors. Differentiating priority ensures logic (Stick) feeds coordinates into rendering layers (Ring) correctly.
const app = new Supermouse();
// Order matters!
app.use(Stick({ padding: 15 })); // Logic: calcs geometry, writes state.shape
app.use(Magnetic({ distance: 100 })); // Logic: adds attraction
app.use(Ring({ size: 30, color: '#fff' })); // Visual: reads state.shape, morphs
app.use(Dot({ size: 8, color: '#000' })); // Visual: renders inner dot
app.use(Trail({ length: 12 })); // Visual: renders trailRuntime Dynamic Control
Call disablePlugin() or enablePlugin() to toggle specific layers on routes or configurations.
const app = new Supermouse();
const magneticPlugin = Magnetic({ distance: 100 });
app.use(magneticPlugin);
// Later, disable on certain pages
document.addEventListener('navigate', (e) => {
if (e.newPage === 'admin') {
app.disablePlugin('magnetic');
} else {
app.enablePlugin('magnetic');
}
});Always write clean visual exits in lifecycle hooks:
definePlugin({
name: 'my-visual',
onEnable(app, el) {
el.style.opacity = '1';
},
onDisable(app, el) {
// Fade out instead of instantly hide
el.style.transition = 'opacity 0.2s';
el.style.opacity = '0';
}
}, options);Configuration Best Practices
Handle Diverse Option Values
Users might pass static numbers, raw strings, or dynamic getters. The normalize() utility compiles any option shape into a clean getter.
import { normalize } from '@supermousejs/utils';
export const MyPlugin = (options) => {
// Normalize accepts: number, string, () => value, or reactive getter
const getSize = normalize(options.size, 20); // default 20
const getColor = normalize(options.color, '#fff'); // default #fff
return definePlugin({
update(app) {
const size = getSize(app.state); // Call to evaluate
const color = getColor(app.state);
}
}, options);
};Standard configurations:
MyPlugin({ size: 30 }); // static
MyPlugin({ size: () => Math.random() * 50 }); // dynamic
MyPlugin({ size: (state) => state.velocity.x }); // reactiveDocument Static Restrictions
Remember that options passed inside constructor calls are static. If users need to adjust size or color dynamically, register methods or expose dynamic getter callbacks in the options.
export interface MyPluginOptions {
name?: string;
isEnabled?: boolean;
/** Size in pixels. Set at construction, not reactive. */
size?: number;
/** Color can be a function for dynamic values. */
color?: ValueOrGetter<string>;
}
export const MyPlugin = (options) => {
let userSize = options.size ?? 20;
return definePlugin({
update(app) {
// Always read userSize, allow external changes
const size = userSize;
}
}, options);
};Common Plugin Patterns
Rotation Syncing
Rotate elements in visual hooks based on cursor travel direction using math.lerpAngle.
import { math } from '@supermousejs/utils';
let currentRotation = 0;
update(app, el) {
const targetRotation = app.state.angle;
// Smooth rotation to prevent jitter
const smoothing = 0.15;
currentRotation = math.lerpAngle(currentRotation, targetRotation, smoothing);
dom.setTransform(el, x, y, currentRotation);
}Sleep Timeouts
Reset elements to default idle configurations when pointer velocities approach zero using performance timestamps.
let stopTime = performance.now();
let isMoving = false;
update(app) {
const speed = math.dist(app.state.velocity.x, app.state.velocity.y);
if (speed > 1) {
isMoving = true;
stopTime = performance.now();
} else {
const stopDuration = performance.now() - stopTime;
if (stopDuration > 500) { // After 500ms idle
isMoving = false;
// Reset visuals
}
}
}Precise Element Centering
Align custom visuals directly on the active cursor using dom.setTransform.
const create = (app) => {
const el = document.createElement('div');
el.style.position = 'fixed';
el.style.pointerEvents = 'none';
app.container.appendChild(el);
return el;
};
const update = (app, el) => {
const { x, y } = app.state.smooth;
// setTransform automatically centers with translate(-50%, -50%)
dom.setTransform(el, x, y);
};Debugging & Troubleshooting
Visual Tearing / Lag
Symptom: The cursor inner dot tracks perfectly, but outer follower layers jitter or drift.
Fix: Ensure logic-modifying plugins (e.g. Magnetics, Snappers) use a negative priority value so they run before visual layers read coordinates.
// ❌ WRONG - Priority causes visual sync mismatch
priority: 0,
update(app) { app.state.target.x += 5; }
// ✅ CORRECT - Modifies target before physics or visuals run
priority: -10,
update(app) { app.state.target.x += 5; }Layout Stutters
Symptom: Scrolling or rendering skips frames on hover shifts.
Fix: Stop reading sizes inside frame callbacks. Use app.registerHoverTarget() and cache client boundaries only when the hover target changes.
// ❌ WRONG - Queries DOM rect every single frame
update(app) {
const rect = el.getBoundingClientRect();
}
// ✅ CORRECT - Scrapes targeting class once, caches calculations
let cachedRect = null;
install(app) {
app.registerHoverTarget('[data-my-attr]');
}
update(app) {
if (app.state.hoverTarget !== lastTarget) {
lastTarget = app.state.hoverTarget;
cachedRect = dom.projectRect(lastTarget, app.container);
}
}Audit Configuration
Run the built-in diagnostic test inside the developer console:
import { doctor } from '@supermousejs/utils';
// Run in browser console
doctor();Advanced Patterns
Deduplicate Core Instantiations
Avoid mounting multiple identical visual plugins. Let one coordinator manage multiple variants to keep DOM trees light.
// ❌ WRONG - Creates new HTML element per plugin
app.use(Dot({ color: 'red' }));
app.use(Dot({ color: 'blue' })); // Wasteful, no deduplicationStateful Toggling
Implement state cleanups in `onEnable` and `onDisable` hooks to prevent garbage coordinates carrying over on toggle events.
definePlugin({
name: 'my-plugin',
onEnable(app, el) {
// Reset to initial state
el.style.opacity = '1';
el.style.transform = '';
currentPos.x = 0;
currentPos.y = 0;
},
onDisable(app, el) {
// Clean visual exit (fade, reset)
el.style.transition = 'opacity 0.3s ease';
el.style.opacity = '0';
}
}, options);Responsive Damping shifts
Scale cursor footprint sizes or turn off visual tracking altogether on narrow viewports to avoid blocking click actions.
const getSize = normalize(options.size, 20);
return definePlugin({
create: (app) => {
const el = dom.createCircle(20, 'white');
// Resize on window change
const handleResize = () => {
const isMobile = window.innerWidth < 768;
el.style.width = isMobile ? '12px' : '20px';
};
window.addEventListener('resize', handleResize);
return el;
},
destroy() {
window.removeEventListener('resize', handleResize);
}
}, options);Publishing & Community
Naming Convention
Keep scope clear: name scopes as supermouse-plugin-xyz or @scope/supermouse-xyz. Organise namespace organization under @supermousejs/* organization naming.
Package Meta definition
Define a meta.json package wrapper configuration:
{
"name": "my-plugin",
"description": "Short description of what it does",
"author": "Your Name",
"repository": "https://github.com/yourname/supermouse-plugin-xyz",
"npm": "supermouse-plugin-xyz"
}Performance Checklist
- Logic plugins run on negative priority (
-10or lower) - No direct DOM layout queries inside the update loop
- Style updates go through
dom.setStyle - Render alignment uses GPU transform translation vectors
- Movements are multiplied against delta timers or damp coefficients
- No object array instantiations inside frame updates
- Targets boundaries calculations are cached on target changes
- Interaction reads fetch from pre-scraped
state.interactioncaches - Elements fade out smoothly when deactivated
- Plugins use factory scopes to prevent memory leaks
Quick Reference: Common Compositions
Magnetic Snap Combos
Combine Magnetic snappers with circular visual elements to pull attention to clickable targets cleanly.
import { Supermouse } from '@supermousejs/core';
import { Magnetic } from '@supermousejs/magnetic';
const app = new Supermouse({ hideCursor: true });
app.use(
Magnetic({
strength: 0.2,
radius: 160,
easing: 'ease-out',
})
);
Momentum Trails
Pair Trail plugins with physics damping variables to create organic movements that draw visual interest.
import { Supermouse } from '@supermousejs/core';
import { Trail } from '@supermousejs/trail';
const app = new Supermouse({ hideCursor: true });
app.use(
Trail({
length: 12,
color: '#eab308',
width: 8,
decay: 0.25,
})
);