# Cursor (Moonarc)

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.

- Import: `import Cursor from '@moonarc/core/Cursor'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/cursor.json`
- Tier B · category pointer · trigger pointer
- Readout: `<Cursor mode="label">`
- Browser support: widely (every browser)
- Measured cost: 1021 B raw JS · 574 B gzip · + runtime (with dependencies 2.7 kB raw; CSS 6.0 kB raw)
- Page: https://moonarc.dev/components/cursor/

## Props

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

## Usage

```astro
<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>
```

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

## Craft

- The lag is a CSS transition on translate, on the fast token and the exit curve: close enough to feel attached, late enough to read as a trail. No lerp runs in JavaScript, so the follow costs one style write per move.
- Targets are found by one pointerover listener on the document (or the host) through closest(), never a listener per element, so a page with three hundred links pays nothing per link.
- Over input, textarea, select and contenteditable the custom cursor hides: the I-beam is information the ring would cover.
- Never installed where (hover: hover) and (pointer: fine) do not both hold, and never under reduced motion. On a phone, a custom cursor would be a stuck dot.
- pointerout with no relatedTarget means the pointer left the window; the cursor hides rather than freezing at the edge.

## Replaces

- SplashCursor / BlobCursor (React Bits)
- FollowerPointerCard (Aceternity)
- Cursor (Motion Primitives)
- SmoothCursor (Magic UI)

## Source

```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>

```
