Image Trail
PointerSmall images pop up along the pointer's path and fade behind it.
A custom pointer that trails the real one: a dot, a ring that grows over links and buttons, or a label ("View") any element can ask for with data-cursor-label. One fixed element; the script writes the position and a state, CSS transitions the lag, the growth and the pill on the preset curve. Not installed on touch or under reduced motion.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
1021 B raw
574 B gzip.
Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.7 kB raw.
CSS 6.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
every browser.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Cursor from '@moonarc/core/Cursor';
---
<Cursor mode="label" />
<a href="/work/one" data-cursor-label="View">
<img src="/one.jpg" alt="" />
</a>
<!-- inside one section only -->
<section class="relative">
<Cursor mode="ring" within />
…
</section>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/cursor.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* Cursor — a custom pointer that trails the real one and reacts to what it
* is over: a dot, a ring that grows on links and buttons, or a label
* ("View") that any element can ask for with data-cursor-label.
*
* One fixed element. The script writes the pointer position as two custom
* properties and one state attribute; the lag, the growth and the label
* morph are CSS transitions on the preset's curve, so the follow has the
* same character as everything else on the page and no lerp runs in JS.
* Targets are delegated: one pointerover listener on the document reads
* `a, button, [data-cursor]`, never a listener per element. Not installed on
* touch, coarse pointers or reduced motion; hidden over text fields so the
* native I-beam does its job; gone when the pointer leaves the window.
* `within` confines it to the parent element (a section, a card, a demo);
* hideNative is a :has() rule on the state, so the script never touches <html>.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** dot: a small disc · ring: a disc that grows over targets · label: a disc that becomes a pill with text over labelled targets (and grows over the rest). */
mode?: 'dot' | 'ring' | 'label';
/** Disc diameter in px. */
size?: number;
/** Growth over targets, as a multiple of size. */
grow?: number;
/** mix-blend-mode: difference — inverts what it covers. Reads badly on a light theme unless the disc is dark. */
blend?: boolean;
/** Hide the native cursor everywhere except over text fields. */
hideNative?: boolean;
/** Confine to the parent element: positioned absolutely inside it, pointer read from it only. */
within?: boolean;
/** Default label text for mode="label" when a target has data-cursor-label without a value. */
label?: string;
}
const { mode = 'ring', size = 12, grow = 3, blend = false, hideNative = false, within = false, label = 'View', class: className, style, ...rest } = Astro.props;
const inline = [`--ma-cursor-size:${size}px`, `--ma-cursor-grow:${grow}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div
class:list={['ma-cursor', className]}
data-ma-cursor
data-mode={mode}
data-state="hidden"
data-blend={blend ? '' : undefined}
data-hide-native={hideNative ? '' : undefined}
data-within={within ? '' : undefined}
data-label={label}
aria-hidden="true"
style={inline}
{...rest}
><span class="ma-cursor__label"></span></div>
<style is:global>
@layer components {
:where(.ma-cursor) {
position: fixed;
left: 0;
top: 0;
z-index: 2147483000;
display: grid;
place-items: center;
box-sizing: border-box;
width: var(--ma-cursor-size, 12px);
height: var(--ma-cursor-size, 12px);
border-radius: 999px;
background: var(--ma-ink);
color: var(--ma-panel);
pointer-events: none;
/* --ma-cursor-x / --ma-cursor-y: the pointer, written by the script; the disc is centred on it */
translate: calc(var(--ma-cursor-x, -100px) - 50%) calc(var(--ma-cursor-y, -100px) - 50%);
scale: 1;
opacity: 1;
/* the lag is the fast token on the exit curve: close enough to feel attached, late enough to read as a trail */
transition:
translate var(--ma-duration-fast) var(--ma-ease-out),
scale var(--ma-duration) var(--ma-ease),
width var(--ma-duration) var(--ma-ease),
height var(--ma-duration) var(--ma-ease),
opacity var(--ma-duration-fast) var(--ma-ease-out),
border-radius var(--ma-duration) var(--ma-ease);
}
:where(.ma-cursor[data-within]) {
position: absolute;
z-index: 10;
}
:where(.ma-cursor[data-state='hidden']) {
opacity: 0;
scale: 0.5;
}
/* ring and label grow over links, buttons and anything with data-cursor */
:where(.ma-cursor[data-mode='ring'][data-state='hover']),
:where(.ma-cursor[data-mode='label'][data-state='hover']) {
scale: var(--ma-cursor-grow, 3);
opacity: 0.35;
}
:where(.ma-cursor[data-mode='dot'][data-state='hover']) {
scale: 1.6;
}
/* the label: the disc widens into a pill; the text fades in after the pill has room */
:where(.ma-cursor__label) {
display: block;
padding: 0 0.9em;
font: 500 0.75rem/1 var(--font-sans, system-ui, sans-serif);
letter-spacing: 0.02em;
white-space: nowrap;
opacity: 0;
transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
}
:where(.ma-cursor[data-state='label']) {
width: auto;
height: calc(var(--ma-cursor-size, 12px) * 2.4);
min-width: calc(var(--ma-cursor-size, 12px) * 2.4);
opacity: 1;
scale: 1;
}
:where(.ma-cursor[data-state='label'] .ma-cursor__label) {
opacity: 1;
transition-delay: var(--ma-duration-fast);
}
:where(.ma-cursor[data-blend]) {
mix-blend-mode: difference;
background: #fff;
color: #000;
}
:where(.ma-cursor[data-state='down']) {
scale: 0.7;
}
/* hideNative: while the custom cursor is live, the native one is off everywhere but text fields — CSS reads the state, no script.
Only where the disc can show: reduced motion switched on after the script bound (or the page's own switch) hides the disc
below and gives the native pointer back here, instead of leaving no pointer at all */
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
:where(html:not([data-ma-motion='reduce']):has(.ma-cursor[data-hide-native]:not([data-state='hidden']))) :where(*:not(input, textarea, select, [contenteditable=''], [contenteditable='true'])) {
cursor: none;
}
}
/* touch, coarse pointers and reduced motion: never shown — the script does not bind either */
@media (hover: none), (pointer: coarse), (prefers-reduced-motion: reduce) {
:where(.ma-cursor) {
display: none;
}
}
}
</style>
<script>
import { onMount } from '../lib/runtime';
onMount<HTMLElement>('[data-ma-cursor]', (el, { signal, reducedMotion }) => {
if (reducedMotion || !matchMedia('(hover:hover) and (pointer:fine)').matches) return;
const within = el.hasAttribute('data-within');
const host = within ? el.parentElement! : document;
let over = 'idle';
const state = (v: string) => (el.dataset.state = v);
const on = (type: string, fn: (e: PointerEvent) => void) => host.addEventListener(type, fn as EventListener, { signal, passive: true });
on('pointermove', (e) => {
const r = within ? (host as HTMLElement).getBoundingClientRect() : { left: 0, top: 0 };
el.style.setProperty('--ma-cursor-x', `${e.clientX - r.left}px`);
el.style.setProperty('--ma-cursor-y', `${e.clientY - r.top}px`);
if (el.dataset.state !== 'down') state(over);
});
// one delegated listener: links, buttons and data-cursor targets grow the ring; text fields hide it; data-cursor-label names it
on('pointerover', (e) => {
const g = e.target as HTMLElement;
const t = g.closest('a,button,[data-cursor]');
const text = t?.getAttribute('data-cursor-label');
over = g.matches('input,select,textarea,option') || g.isContentEditable ? 'hidden' : !t ? 'idle' : el.dataset.mode === 'label' && text != null ? ((el.firstChild!.textContent = text || el.dataset.label!), 'label') : 'hover';
state(over);
});
on('pointerdown', () => state('down'));
on('pointerup', () => state(over));
// out of the window (or the host): nothing under the pointer is ours
on('pointerout', (e) => (within ? !(host as Node).contains(e.relatedTarget as Node) : !e.relatedTarget) && state('hidden'));
signal.addEventListener('abort', () => state('hidden'));
});
</script>| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'dot' | 'ring' | 'label' | 'ring' | dot: a small disc · ring: grows over targets · label: becomes a pill with text over targets that carry data-cursor-label, grows over the rest. |
size | number | 12 | Disc diameter in px. |
grow | number | 3 | Growth over targets, as a multiple of size. |
blend | boolean | false | mix-blend-mode: difference, so the disc inverts what it covers. Off by default: on a light theme a light disc disappears. |
hideNative | boolean | false | Hide the native cursor while the custom one is live, everywhere except over text fields. |
within | boolean | false | Confine to the parent element: positioned inside it, pointer read from it only, hidden when the pointer leaves it. |
label | string | 'View' | Text for targets that carry data-cursor-label with no value. |
Not installed: the element is hidden and the script never binds, so the native cursor is the only one. Switched on while the page is open (or through the page's own motion switch), the disc hides and hideNative gives the native cursor back.
ClientRouterBound through the shared runtime: the listeners are aborted before the swap and the cursor returns to its hidden state, which ends hideNative (a :has() rule on that state, nothing written to <html>); the next page binds its own.
Replaces: SplashCursor / BlobCursor (React Bits) · FollowerPointerCard (Aceternity) · Cursor (Motion Primitives) · SmoothCursor (Magic UI). See the migration table.
Small images pop up along the pointer's path and fade behind it.
A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves.
A soft radial light follows the cursor over a card and fades in only while hovered.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown