Skip to content

Components / Pointer

Spotlight

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.

Live demo

Hover.

target="border"
Edge.

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

Measured

JavaScript of its own

340 B raw

250 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.0 kB raw.

CSS 5.2 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 120 · Firefox 53 · Safari 15.4; needs mask-composite.

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 Spotlight from '@moonarc/core/Spotlight';
---
<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>
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/spotlight.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.

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

Props

PropTypeDefaultDescription
asHTMLTag'div'Element to render.
radiusnumber240Radius of the light in px.
colorstringvar(--ma-glow)Light colour; the token is theme-aware.
opacitynumber1Strength 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.
bordernumber1Ring width in px for target="border"; match the card's border.

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.

Why it is built this way

Replaces: SpotlightCard (React Bits) · MagicCard (Magic UI) · CardSpotlight (Aceternity). See the migration table.

Border Beam

Backgrounds

A beam of light travels around the edge of a card.

newly · Chrome 120 · Firefox 128 · Safari 16.4always

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.

every browserpointeralways

Tilt

Pointer

A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.

every browserpointerhover

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