Skip to content

Components / Pointer

Cursor

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 demo

ring · label

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/cursor.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

Cursor.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
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.
sizenumber12Disc diameter in px.
grownumber3Growth over targets, as a multiple of size.
blendbooleanfalsemix-blend-mode: difference, so the disc inverts what it covers. Off by default: on a light theme a light disc disappears.
hideNativebooleanfalseHide the native cursor while the custom one is live, everywhere except over text fields.
withinbooleanfalseConfine to the parent element: positioned inside it, pointer read from it only, hidden when the pointer leaves it.
labelstring'View'Text for targets that carry data-cursor-label with no value.

Reduced motion

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.

With ClientRouter

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

Why it is built this way

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.

every browserpointer

Magnetic

Pointer

A button or icon is pulled toward the cursor while it is nearby and springs back when it leaves.

every browserpointerhover

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown