Skip to content

Components / Pointer

Cursor Grid

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.

Live demo

move your cursor · 0 B

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

Measured

JavaScript of its own

0 B

CSS 5.4 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

Chrome 105 · Firefox 121 · Safari 15.4; needs has.

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 CursorGrid from '@moonarc/core/CursorGrid';
---
<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>
Copy it into your project instead (shadcn registry)

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

terminal
npx shadcn@latest add https://moonarc.dev/r/cursor-grid.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; it imports nothing.

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

Props

PropTypeDefaultDescription
colsnumber32Columns, max 96. Cells are cols × rows empty elements: the cost is HTML, about 10 B raw a cell.
rowsnumber16Rows, max 64.
colorstring'currentColor'Colour of a lit cell.
fadenumber900Milliseconds a cell takes to go dark after the cursor leaves. Longer means a longer trail.
idlebooleantrueA soft band drifts across the field when nothing is hovered; pauses on hover.
linesbooleantrueDraw the hairline grid between cells.

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.

Why it is built this way

Replaces: InteractiveGridPattern (Magic UI) · DotGrid (React Bits). See the migration table.

Ambient gradient field: three soft colour fields drifting at three coprime speeds, a slow hue rotation so the colours themselves move, and a film-grain layer stepped at 12 fps.

every browseralways

A field of braille dots that reads as a flowing contour map: two waves cross it, the colour turns along a sunset arc, and the cursor carves a trail that decays one dot at a time.

newly · Chrome 125 · Firefox 128 · Safari 17.2pointeralways

Grid Pattern

Backgrounds

A hairline grid behind content, faded toward the edges, optionally drifting one cell at a time.

every browseralways

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