Border Beam
BackgroundsA beam of light travels around the edge of a card.
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.
Hover.
target=
Edge.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
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
Chrome 120 · Firefox 53 · Safari 15.4; needs mask-composite.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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>Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.
npx shadcn@latest add https://moonarc.dev/r/spotlight.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.
---
/**
* 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>| 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. |
The light still follows the cursor (direct manipulation, not motion) but no longer fades in and out.
ClientRouterBound through the shared runtime: listeners are attached to every instance after each navigation and aborted before the swap.
Replaces: SpotlightCard (React Bits) · MagicCard (Magic UI) · CardSpotlight (Aceternity). See the migration table.
A beam of light travels around the edge of a card.
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.
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.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown