# Cursor Grid (Moonarc)

A field of cells that light up under the cursor and fade where it has been, with a slow idle sweep when nothing is touched. Zero JavaScript: every cell is an element whose own hover state decays on a transition, so the trail is the browser doing the work. Fills its positioned parent.

- Import: `import CursorGrid from '@moonarc/core/CursorGrid'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/cursor-grid.json`
- Tier A · category pointer · trigger pointer, always
- Readout: `<CursorGrid cols={32} rows={16}>`
- Browser support: widely (Chrome 105 · Firefox 121 · Safari 15.4)
- Measured cost: 0 B JS (CSS 5.4 kB raw)
- Page: https://moonarc.dev/components/cursor-grid/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `cols` | `number` | `32` | Columns, max 96. Cells are cols × rows empty elements: the cost is HTML, about 10 B raw a cell. |
| `rows` | `number` | `16` | Rows, max 64. |
| `color` | `string` | `'currentColor'` | Colour of a lit cell. |
| `fade` | `number` | `900` | Milliseconds a cell takes to go dark after the cursor leaves. Longer means a longer trail. |
| `idle` | `boolean` | `true` | A soft band drifts across the field when nothing is hovered; pauses on hover. |
| `lines` | `boolean` | `true` | Draw the hairline grid between cells. |

## Usage

```astro
<section class="relative min-h-[60vh]">
  <CursorGrid color="oklch(78% 0.16 60)" />
  <h1 class="relative">Move your cursor. Zero bytes of JavaScript.</h1>
</section>
```

## Reduced motion

The idle sweep stops; cells still light under the cursor but release in 200 ms instead of trailing.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- Lit in 0 ms, released over 900 ms: the asymmetry is what reads as a trail. A symmetric fade reads as lag.
- Direct neighbours (previous and next sibling) light to 35% so a fast cursor never leaves single isolated squares.
- The idle sweep is a translate on one pseudo-element (compositor only) and pauses the moment the field is hovered so it never fights the cursor.
- contain: strict and aria-hidden: 512 elements the layout engine and assistive tech can both ignore.
- Content sits above the grid with position: relative; cells under content do not receive hover, which is correct because the cursor is on the text.

## Replaces

- InteractiveGridPattern (Magic UI)
- DotGrid (React Bits)

## Source

```astro
---
/**
 * CursorGrid — a field of cells that light where the cursor is and fade where
 * it was. Zero JS: every cell is an element with its own :hover, lit
 * instantly and released on a slow transition, so the trail is the browser's
 * own hover state decaying. An idle sweep keeps the field alive when nothing
 * touches it. Fills its positioned parent; put content above it.
 *
 * The cost is in the HTML, not the script: cols × rows empty elements
 * (32 × 16 = 512 by default, ~5 kB of markup, ~120 B gzipped).
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Columns. */
  cols?: number;
  /** Rows. */
  rows?: number;
  /** Colour of a lit cell; any CSS colour. */
  color?: string;
  /** How long a cell takes to go dark after the cursor leaves it, ms. */
  fade?: number;
  /** Slow idle sweep across the field when nothing is hovered. */
  idle?: boolean;
  /** Show the hairline grid between cells. */
  lines?: boolean;
}

const { cols = 32, rows = 16, color = 'currentColor', fade = 900, idle = true, lines = true, class: className, style, ...rest } = Astro.props;
const count = Math.max(1, Math.min(cols, 96)) * Math.max(1, Math.min(rows, 64));
const vars = [`--ma-cg-cols:${cols}`, `--ma-cg-rows:${rows}`, `--ma-cg-color:${color}`, `--ma-cg-fade:${fade}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div
  class:list={['ma-cursor-grid', className]}
  aria-hidden="true"
  data-idle={idle ? '' : undefined}
  data-lines={lines ? '' : undefined}
  style={vars}
  {...rest}
>
  {Array.from({ length: count }, () => <i></i>)}
</div>

<style is:global>
  @layer components {
    :where(.ma-cursor-grid) {
      position: absolute;
      inset: 0;
      display: grid;
      grid-template-columns: repeat(var(--ma-cg-cols, 32), 1fr);
      grid-template-rows: repeat(var(--ma-cg-rows, 16), 1fr);
      overflow: hidden;
      contain: strict;
    }
    :where(.ma-cursor-grid[data-lines]) {
      background-image:
        linear-gradient(to right, var(--ma-edge) 1px, transparent 1px),
        linear-gradient(to bottom, var(--ma-edge) 1px, transparent 1px);
      background-size: calc(100% / var(--ma-cg-cols, 32)) calc(100% / var(--ma-cg-rows, 16));
    }
    :where(.ma-cursor-grid > i) {
      display: block;
      background: var(--ma-cg-color, currentColor);
      opacity: 0;
      transition: opacity var(--ma-cg-fade, 900ms) var(--ma-ease-out);
    }
    /* lit at once, released slowly — the trail is the hover state decaying */
    :where(.ma-cursor-grid > i:hover) {
      opacity: 0.9;
      transition-duration: 0ms;
    }
    :where(.ma-cursor-grid > i:hover + i),
    :where(.ma-cursor-grid > i:has(+ i:hover)) {
      opacity: 0.35;
      transition-duration: 60ms;
    }
    /* idle sweep: a soft band drifting across the field, paused while the cursor is in it */
    :where(.ma-cursor-grid[data-idle])::after {
      content: '';
      position: absolute;
      inset: -20% -60%;
      background: linear-gradient(100deg, transparent 40%, var(--ma-cg-color, currentColor) 50%, transparent 60%);
      opacity: 0.12;
      will-change: translate;
      animation: ma-cursor-grid-sweep 9s var(--ma-ease-in-out) infinite alternate;
      pointer-events: none;
    }
    :where(.ma-cursor-grid[data-idle]:hover)::after {
      animation-play-state: paused;
      opacity: 0;
      transition: opacity 600ms;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-cursor-grid[data-idle])::after {
        animation: none;
        opacity: 0;
      }
      :where(.ma-cursor-grid > i) {
        transition-duration: 200ms;
      }
    }
  }

  @keyframes ma-cursor-grid-sweep {
    from {
      translate: -35% 0;
    }
    to {
      translate: 35% 0;
    }
  }
</style>

```
