Skip to content

Components / Backgrounds

Border Beam

A beam of light travels around the edge of a card. A conic gradient behind two composited masks leaves only the ring; the start angle animates as a registered custom property. Pure CSS, inherits the parent radius.

Live demo

Pro

Sections, composed.

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.1 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 · newly available

Chrome 120 · Firefox 128 · Safari 16.4; needs property, mask-image. Elsewhere: the beam holds still at its start angle.

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 BorderBeam from '@moonarc/core/BorderBeam';
---
<div class="relative rounded-2xl border p-6">
  <BorderBeam />
  <h3>Pro</h3>
</div>
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/border-beam.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.

BorderBeam.astro
---
/**
 * BorderBeam — a light travels around the edge of a card, zero JS.
 *
 * The element carries a conic gradient whose start angle is a registered
 * custom property; two masks composited with `exclude` keep only the border
 * ring. The angle grows, so the beam runs clockwise and the head colour sits
 * at the leading end. Animating the angle repaints the ring, nothing else.
 * Absolutely positioned: give the parent `position: relative` and a radius.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Seconds per lap. */
  duration?: number;
  /** Length of the beam as a fraction of the perimeter, 0.05–0.5. */
  length?: number;
  /** Ring thickness in px. */
  width?: number;
  /** Beam colours: head and tail. */
  colors?: [string, string];
  /** Start angle in degrees. */
  from?: number;
}

const { duration = 6, length = 0.2, width = 1.5, colors = ['oklch(78% 0.16 60)', 'oklch(70% 0.19 300)'], from = 0, class: className, style, ...rest } = Astro.props;
const span = Math.max(0.05, Math.min(length, 0.5)) * 360;
const vars = [`--ma-beam-duration:${duration}s`, `--ma-beam-span:${span}deg`, `--ma-beam-width:${width}px`, `--ma-beam-1:${colors[0]}`, `--ma-beam-2:${colors[1]}`, `--ma-beam-from:${from}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div
  class:list={['ma-beam', className]}
  aria-hidden="true"
  style={vars}
  {...rest}
>
</div>

<style is:global>
  @property --ma-beam-angle {
    syntax: '<angle>';
    inherits: false;
    initial-value: 0deg;
  }

  @layer components {
    :where(.ma-beam) {
      position: absolute;
      inset: 0;
      border-radius: inherit;
      pointer-events: none;
      padding: var(--ma-beam-width, 1.5px);
      /* clockwise, so the head is the far end of the span: transparent at the start, the tail colour 60% of the length
         behind the head, and a hard edge at the head. Without @property the angle falls back to 0 and the beam holds
         still at `from` (the keyframe then flips it from 0 to a full turn, the same place) */
      background: conic-gradient(from calc(var(--ma-beam-angle, 0deg) + var(--ma-beam-from, 0deg)), transparent 0deg, var(--ma-beam-2) calc(var(--ma-beam-span, 72deg) * 0.4), var(--ma-beam-1) var(--ma-beam-span, 72deg), transparent var(--ma-beam-span, 72deg));
      -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;
      animation: ma-beam var(--ma-beam-duration, 6s) linear infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-beam) {
        animation: none;
      }
    }
  }

  @keyframes ma-beam {
    to {
      --ma-beam-angle: 360deg;
    }
  }
</style>

Props

PropTypeDefaultDescription
durationnumber6Seconds per lap. Under 3 reads as a spinner.
lengthnumber0.2Beam length as a fraction of the perimeter, 0.05–0.5.
widthnumber1.5Ring thickness in px.
colors[string, string]amber, violetHead and tail colours.
fromnumber0Start angle in degrees; offset several cards so they do not sync.

Reduced motion

The beam holds still at its start angle (from).

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: BorderBeam (Magic UI) · MovingBorder (Aceternity) · StarBorder (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

Spotlight

Pointer

A soft radial light follows the cursor over a card and fades in only while hovered.

every browserpointerhover

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