# Spotlight (Moonarc)

A soft radial light follows the cursor over a card and fades in only while hovered. One pointermove listener writes two custom properties; the light itself is a CSS gradient. Bound through the shared runtime, so it survives navigation.

- Import: `import Spotlight from '@moonarc/core/Spotlight'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/spotlight.json`
- Tier B · category pointer · trigger pointer, hover
- Readout: `<Spotlight radius={240}>`
- Browser support: widely (Chrome 120 · Firefox 53 · Safari 15.4)
- Measured cost: 340 B raw JS · 250 B gzip · + runtime (with dependencies 2.0 kB raw; CSS 5.2 kB raw)
- Page: https://moonarc.dev/components/spotlight/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `radius` | `number` | `240` | Radius of the light in px. |
| `color` | `string` | `var(--ma-glow)` | Light colour; the token is theme-aware. |
| `opacity` | `number` | `1` | Strength while hovered, 0–1. |
| `target` | `'surface' | 'border'` | `'surface'` | Where the light lives: across the surface, or only on the card's edge as a ring that follows the cursor (Magic Card). The same two custom properties drive both; the ring is BorderBeam's two-mask trick, zero extra bytes. |
| `border` | `number` | `1` | Ring width in px for target="border"; match the card's border. |

## Usage

```astro
<Spotlight class="rounded-2xl border p-8">
  <h3>Hover me</h3>
</Spotlight>
<Spotlight target="border" color="var(--action)" radius={160} class="rounded-2xl border p-8">
  <h3>The light stays on the edge</h3>
</Spotlight>
```

## Reduced motion

The light still follows the cursor (direct manipulation, not motion) but no longer fades in and out.

## With ClientRouter

Bound through the shared runtime: listeners are attached to every instance after each navigation and aborted before the swap.

## Craft

- The gradient is positioned by custom properties, so the move handler sets two values and never touches layout.
- Fade-in is gated on (hover: hover): on touch there is no cursor to follow, so the light stays off rather than sticking where a finger was.
- passive listener; getBoundingClientRect is the only read, once per move.

## Replaces

- SpotlightCard (React Bits)
- MagicCard (Magic UI)
- CardSpotlight (Aceternity)

## Source

```astro
---
/**
 * Spotlight — a soft radial light follows the cursor over a surface.
 *
 * One pointermove listener writes the cursor position into two custom
 * properties; the light is a pseudo-element gradient positioned by them and
 * faded in by :hover. Nothing is measured on move except the box, and the
 * listener is bound through the runtime so it survives navigation.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Radius of the light in px. */
  radius?: number;
  /** Light colour. */
  color?: string;
  /** Strength, 0–1. */
  opacity?: number;
  /** Where the light lives: the surface, or only the card's edge (a ring the same width as `border`). */
  target?: 'surface' | 'border';
  /** Ring width in px for target="border". */
  border?: number;
}

const { as: Tag = 'div', radius = 240, color = 'var(--ma-glow)', opacity = 1, target = 'surface', border = 1, class: className, style, ...rest } = Astro.props;
const inline = [`--ma-spot-r:${radius}px`, `--ma-spot-c:${color}`, `--ma-spot-o:${opacity}`, target === 'border' && `--ma-spot-w:${border}px`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-spot', className]} data-ma-spot data-target={target === 'border' ? 'border' : undefined} style={inline} {...rest}>
  <slot />
</Tag>

<style is:global>
  @layer components {
    :where(.ma-spot) {
      position: relative;
      overflow: hidden;
    }
    :where(.ma-spot)::before {
      content: '';
      position: absolute;
      inset: 0;
      pointer-events: none;
      background: radial-gradient(var(--ma-spot-r, 240px) circle at var(--ma-spot-x, 50%) var(--ma-spot-y, 50%), var(--ma-spot-c, var(--ma-glow)), transparent 70%);
      opacity: 0;
      transition: opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    /* border: the same gradient, kept to a ring by two masks composited with exclude (BorderBeam's ring), over the card's own border */
    :where(.ma-spot[data-target='border'])::before {
      inset: calc(-1 * var(--ma-spot-w, 1px));
      padding: var(--ma-spot-w, 1px);
      border-radius: inherit;
      -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
      -webkit-mask-composite: xor;
      mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
      mask-composite: exclude;
    }
    :where(.ma-spot[data-target='border']) {
      overflow: visible;
    }
    @media (hover: hover) {
      :where(.ma-spot:hover)::before {
        opacity: var(--ma-spot-o, 1);
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-spot)::before {
        transition: none;
      }
    }
  }
</style>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-spot]', (el, { signal }) => {
    el.addEventListener(
      'pointermove',
      (e) => {
        // the rect is in screen pixels, the gradient in the element's own: under a scaled ancestor they differ (a pointer
        // over the element means it has a size, so the ratio is defined)
        const r = el.getBoundingClientRect();
        el.style.setProperty('--ma-spot-x', `${((e.clientX - r.left) * el.offsetWidth) / r.width}px`);
        el.style.setProperty('--ma-spot-y', `${((e.clientY - r.top) * el.offsetHeight) / r.height}px`);
      },
      { signal, passive: true },
    );
  });
</script>

```
