Developer Reference
The public integration contract for themes, plugins and widget authors. It is deliberately small: four surfaces plus one kill-switch, all at the wire level. Everything here is additive-only from 1.0 — keys are never renamed or repurposed, and unknown payload keys are ignored by the engine. The plugin's Elementor control machinery (Site Settings, the per-widget sections) is internal and may change freely; integrate against what's on this page, not against control IDs.
Discovery
Standalone browser startup
pnpm build:library produces ESM, declarations and CSS independently of the WordPress release. The package root is passive; start and retire an app explicitly:
import { createCursorApp } from '@arts/cursor-follower'
import '@arts/cursor-follower/styles.css'
const app = createCursorApp({ options: { trailing: 0.15 } })
app.init()
// At host teardown:
app.destroy()For pointer-gated downloads, import only /gate. Serve the packaged CSS at the configured URL and use a dynamic import after it loads:
import { createCursorGate } from '@arts/cursor-follower/gate'
const gate = createCursorGate({
css: '/assets/cursor.css',
async load(signal) {
const { createCursorApp } = await import('@arts/cursor-follower')
createCursorApp({ signal }).init()
}
})
gate.init()
// Cancels pending downloads/initialization and disposes the owned app:
gate.destroy()Always pass the captured signal through the asynchronous boundary. A retired gate cannot start an app even if its module finishes downloading later. App replacement preserves window.artsCursor, its observers and first-ready promise. Destroyed app and gate handles are terminal; create a new handle to start again.
Default exports resolve to built ESM/declarations. The arts-source condition selects shipped TypeScript (enable resolveJsonModule for canonical version metadata). /styles.scss provides source Sass, /styles.css compiled CSS, and /contract provides engine-free constants and discovery types. No WordPress globals or version defines are required.
Runtime discovery
The gate installs window.artsCursor at parse time on every device:
interface IArtsCursorGlobal {
/** Resolves with the instance at engine init — possibly late (the engine
loads on the first pointer signal) or never (touch device). */
ready: Promise<ICursorFollower>
get(): ICursorFollower | null
observe(listener: (cursor: ICursorFollower | null) => void,
options?: { signal?: AbortSignal }): () => void
version: string
}observe() synchronously replays the live initialized instance or null, then reports lifecycle changes. Capability-disabled cursors remain connected; use enabled and enabled:change for pointer capability. The namespace and its subscriptions survive gate-to-engine handoff and boot replacement. destroy() publishes null before internal teardown; destroy(); init() on the same object publishes a new connection. Retained set() and magnetize() release handles are inert once their provider has been disposed.
Pass an owner's AbortSignal or call the returned unsubscribe function. An already-aborted signal receives no callback; abort/unsubscribe is idempotent, including during synchronous replay. Subscriber failures are isolated from the provider. Consumers still own release of resources acquired in their callbacks.
The engine also dispatches a bubbling arts-cursor:ready CustomEvent on document (detail: the instance) at init, after publishing the live cursor. For namespace discovery, listen for it first, immediately inspect the owning document's defaultView.artsCursor, then subscribe once and remove the discovery listener. Observation does not load assets. No polling, promise coordination or legacy fallback is needed.
get() and ready remain useful for imperative first-use operations, but ready resolves only the first initialized instance, not every replacement:
// Fire-and-forget — right for decorative effects. Absent engine, nothing happens.
window.artsCursor?.get()?.hideNativeCursor()
// Await — right when a session must not be missed. May never resolve on touch,
// so never gate critical work behind it.
const cursor = await window.artsCursor.readyThe synchronous "which world" signal is the <html> class — see CSS below.
TypeScript consumers get every type on this page from the package itself: import type { IArtsCursorGlobal, ICursorPayload } from '@arts/cursor-follower'.
Per-element payloads
One attribute, one JSON object — on any element you render:
<a href="/work/aurora" data-arts-cursor-follower-target='{"shape":"pill","label":"View Project"}'>…</a>Parsed once per element, matched via delegation — elements added later (AJAX, infinite scroll) need no re-init. Every key, all optional:
| Key | Value | Does |
|---|---|---|
magnetic | true | number | Pull the cursor onto the element; a number overrides the global strength |
scale | "80px" | "target" | {ref, factor, min?} | false | Cursor size over the element — px, the element's own rendered size, or a factor of cursor/target |
anchor | selector | Resolved inside the element: the magnet's pull target when the hover zone is bigger than the visual target |
elementScale | number | Magnetic only. Scales the element itself from its center while engaged — overrides the global magnetic.elementScale. 1 leaves the element's own CSS alone; the press ratio replaces it flat while the button is down |
label | string | Text riding the cursor. Written via textContent — safe for user-typed content |
shape | "pill" | Morph the follower into a filled stadium hugging its content |
icon | SVG/HTML string | Inline markup for the label's icon slot — author-trusted, never end-user input |
iconClass / iconUrl | string | Same slot: webfont classes, or an image URL masked to take the cursor's text color (iconUrl wins) |
iconPosition | "before" | "after" | Which side of the label the icon sits on (default after) |
arrows | "horizontal" | "vertical" | "all" | false | Arrow glyphs around/inside the cursor |
arrowsPosition | "inside" | "outside" | Arrows inside the ring or outside its edge |
pillAxis | "horizontal" | "vertical" | Long axis for a dot-only press pill; label and arrow pills infer it from their content |
press | payload | A sub-payload composed while the primary press is held, before the drag threshold |
drag | payload | A sub-payload pushed while a click-drag is held on the element (its own drag is ignored) |
dot | true | A filled dot scales up at the anchor while pressed; rides the press channel |
highlight | true | false | {scale?} | Force, forbid, or tune the highlight for this element |
hideNativeCursor | true | Hide the OS cursor over the element |
hidden | true | Hide the complete engine graphic independently of the native pointer |
backgroundColor / borderColor / borderWidth / textColor | string / number | Per-element color and stroke overrides |
offset | [x, y] | Shift the whole cursor cluster off the pointer, in px — signed, +x right, +y down |
showLoadingAnimation / showProgressCursor | true | Enter the loading / progress state on hover |
Two behaviors you get without any attribute: interactive elements (a, button, [role="button"], .has-cursor-highlight) auto-highlight, and .no-cursor-highlight opts one out. Localization is yours and upstream of the wire: wrap label in your own __() when printing the attribute from PHP.
Server-side rules
For markup you don't render — other people's widgets, plugin output — add rules through the one PHP filter instead of attributes. The docs engine runs these exact rules; hover the card:
These demos need a mouse or trackpad — cursor effects stay off on touch devices.
<div class="demo-rules-scope">
<a href="#" class="demo-rules-card">This card carries no attribute — the pill comes from a server-side rule</a>
</div><?php
// Display twin of docs/.vitepress/theme/rulesDemoScopes.ts — the docs engine
// runs these exact rules; this is what they look like as the WordPress filter.
add_filter( 'arts_cursor_follower/options', function ( $options ) {
$options['targetScopes'][] = array(
'scope' => '.demo-rules-scope',
'rules' => array(
array(
'selector' => ':scope .demo-rules-card',
'payload' => array(
'shape' => 'pill',
'label' => __( 'Open', 'my-theme' ),
),
),
),
);
return $options;
} );The grammar: targetScopes is a list of {scope, rules} groups. scope is the component root; each rule's selector is resolved against it (:scope = the root, and the default when omitted). Semantics that make it composable:
- Order is priority — the first matching rule wins, so put narrow rules above broad ones (a rule for something inside a drag surface must come first).
- Interactive elements protect themselves — a rule matched on an ancestor never claims an element that is itself interactive; only a rule naming the element directly can. A track-wide drag rule can't swallow the links inside its slides.
payload.anchoris scope-relative and resolves within the hovered instance.labelVar/iconVarname a CSS custom property on the scope element that replaces the rule'slabel/icon per instance — the channel for per-widget wording (url(…)values are masked images; anything else is webfont classes). Named per rule so one rule's label can never leak onto its siblings.stateVarsis the same channel for state rather than content:{ shape: '--my-shape', arrows: '--my-arrows' }takes those payload keys off the hovered instance. A property set tononedrops the key; an unset one leaves the rule's own value standing. Reach for it over a marker class in the selector whenever your scope can nest inside itself — a property resolves against the nearest matching scope, whileclosest()walks past a nearer element that lacks the class to match a farther one that has it.- Rules whose payload carries
highlightdrop out while the site-wide Highlight toggle is off.
The same filter carries the tuning keys (trailing, elastic, magnetic, highlight, pressScale) if you need to override Site Settings programmatically.
Runtime API
window.artsCursor.get() returns the engine:
interface ICursorFollower {
set(payload: ICursorPayload): ICursorSession
loading(opts?: { size?: number }): ICursorSession
progress(): ICursorSession
hideNativeCursor(): ICursorSession
magnetize(opts: IMagnetizeOptions): ICursorSession
suspendTargets(): ICursorTargetSession
updateOptions(partial: ICursorOptions): void // live-tune trailing/elastic/magnetic/highlight/pressScale
warm(container?: ParentNode): void // queue observer-based geometry warming after injecting DOM
remeasure(): void // re-sample measured theming vars after you change them
refresh(): void // re-resolve the hovered target after your state changed under a still pointer (also runs automatically one frame after any click; same verdict emits 'target:refresh' instead of re-entering)
on(event: 'target:enter' | 'target:leave' | 'target:refresh' | 'enabled:change', cb): () => void
readonly enabled: boolean
readonly el: HTMLElement | null
}Sessions stack — last wins per property, and releasing one restores whatever remains (other sessions, then hover state). Every session supports release() and using (Symbol.dispose).
suspendTargets() temporarily releases automatic hover magnetism and clears target press/drag presentation. It leaves explicit set(), loading() and magnetize() sessions in their owner's control, and never changes a host-controlled drag's state. The first lease emits one target:leave, allowing host-derived hover sessions to release themselves. Nested leases keep targets suspended until the last release. During suspension refresh() records invalidation without hit-testing; the final release schedules one fresh resolution from the latest pointer coordinates.
interface ICursorTargetSession extends ICursorSession {
readonly settled: Promise<void>
}
const targets = cursor.suspendTargets()
try {
// Network acquisition can begin before this wait.
await targets.settled
// Finite magnetic element returns have finished and restored their styles.
await prepareAndRunNavigation()
} finally {
targets.release()
}settled includes elements already returning before suspension, but excludes continuous following and explicitly owned magnetism. Releasing the lease or destroying the cursor also resolves it. Hidden-document and disabled-input returns finish immediately. Feature-detect suspendTargets when supporting older installed plugin versions. Loading feedback alone does not suspend targets.
warm() coalesces repeated hints into bounded observer-registration tasks without sweeping layout rectangles synchronously. Cold magnetic engagement still measures its anchor immediately, so moving and sticky targets stay accurate.
magnetize is the programmatic magnet for live, moving anchors — no hover coupling, no distance release; the caller owns the lifecycle. Move the pointer into the zone below: the wiring magnetizes the cursor to the orbiting dot and rides it until you leave — the same recipe a production slider uses to keep the cursor caught on its drag knob:
These demos need a mouse or trackpad — cursor effects stay off on touch devices.
import type { ICursorSession } from '@engine'
let session: ICursorSession | null = null
export function engageOrbit(): void {
if (session) {
return
}
const dot = document.querySelector('.demo-orbit-dot')
if (!dot) {
return
}
session =
window.artsCursor?.get()?.magnetize({
// Page coordinates, read once per frame — the dot is mid-animation.
getAnchor: () => {
const r = dot.getBoundingClientRect()
return {
x: r.left + r.width / 2 + window.scrollX,
y: r.top + r.height / 2 + window.scrollY
}
},
payload: { scale: '44px' },
// Near-zero strain keeps the ring centered on the dot instead of
// straining toward the pointer.
strength: () => 0.05
}) ?? null
}
export function releaseOrbit(): void {
session?.release()
session = null
}getAnchor returns page coordinates read once per frame. strength and trailing also accept per-frame functions — return 0 strength or 1 trailing to glue rigidly (e.g. while dragging), null for the configured defaults.
CSS
<html> classes. Exactly one of has-cursor-follower / no-cursor-follower is always present — set pre-paint from the pointer media query, corrected by the engine if reality differs, flipped to no- on asset failure, and printed server-side when the site disables the cursor via the PHP filter. Key your fallback styling off them and touch devices, filtered pages and broken deploys all land in the same, handled state.
Theming vars. Six custom properties, styleable from any stylesheet:
.arts-cursor {
--arts-cursor-size: 64px;
--arts-cursor-text-color: #fff;
--arts-cursor-loading-color: #fff;
--arts-cursor-border-width: 2px;
--arts-cursor-border-color: #808080;
--arts-cursor-background-color: transparent;
}They are deliberately unregistered (no @property), so var(--x, fallback) chains keep working — set only what you need. These docs theme their own cursor this way, scoped to engine states (.arts-cursor[data-cursor-highlight], [data-cursor-shape='pill']).
The cascade layer. Every rule the plugin ships lives in the arts-cursor layer, so any unlayered site CSS outranks it by definition — no specificity wars. Want a blend mode? One rule, no plugin option needed:
.arts-cursor { mix-blend-mode: difference; }Frosted glass. Two vars carry a whole blur() value rather than a radius, because unset they fall back to none and cost nothing. --arts-cursor-hint-backdrop-filter applies only while the cursor is showing content — a label, an icon, a pill — so the free-roam ring stays a bare outline and the frost fades in with the fill:
.arts-cursor {
--arts-cursor-hint-backdrop-filter: blur(16px);
--arts-cursor-hint-background: rgb(255 255 255 / 0.25);
}--arts-cursor-backdrop-filter is the same thing without the state scoping — it frosts every state, free roam included. The content states are .arts-cursor:is([data-cursor-hint], [data-cursor-shape='pill']) if you want to hang your own rules off them. In WordPress all of this is controls: Site Settings → Cursor for the base, → Hints for the content ones.
One incompatibility, from the CSS spec itself: a non-normal blend mode on .arts-cursor turns the cursor into its own backdrop root, so the blur has nothing to sample and silently does nothing. Pick one per site — frosted glass or difference, not both.
Hint padding. --arts-cursor-hint-padding-x / -y (18px / 8px) set the breathing room around whatever the cursor is showing, and both shapes honour them: the pill pads its stadium, and the circle grows until the padded box fits inside the ring. Sampled once per style epoch rather than per frame, so set them in a stylesheet, not inline per element.
Disabling per request
Conditional loading enqueues nothing (the engine arrives through an inline gate), so there is no script handle to dequeue. The supported switch:
add_filter( 'arts_cursor_follower/enabled', fn( $on ) => $on && ! is_checkout() );Evaluated once per request, before output. A disabled request prints no gate and no globals, and <html> still gets no-cursor-follower (via language_attributes), so the class contract stays total — to your CSS, a filtered page is a touch page. The Elementor editor preview ignores the filter; it always shows the cursor.
Sharing a frame loop
A host that owns a rAF loop can hand it to the engine instead of letting it run its own. The seam is script-side by design — set a ticker on the options global before the engine loads:
// Any script that runs before the engine bundle (the engine loads lazily on
// the first pointer signal, so app code is almost always early enough).
window.artsCursorFollowerOptions = {
...window.artsCursorFollowerOptions,
ticker: myTicker // { subscribe(cb, opts?): () => void }
}The adapter contract is one method — subscribe(callback, { priority?, label? }): unsubscribe — shape-compatible with tempus. Only deltaTime and frameCount are portable across implementations. Best-effort by nature: a pointer move that beats your deferred script boots the engine on its own rAF, which sleeps whenever the cursor converges.
What stays out
No per-widget color/scale controls, no page-level override settings, no configurable attribute name, no panel extensibility for foreign widgets. Where those needs are real, this page's channels cover them: colors are CSS (the vars + the layer), wording is labelVar/your own __(), behavior is the payload or the filter.