# Border Beam (Moonarc)

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.

- Import: `import BorderBeam from '@moonarc/core/BorderBeam'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/border-beam.json`
- Tier A · category background · trigger always
- Readout: `<BorderBeam duration={6}>`
- Browser support: newly (Chrome 120 · Firefox 128 · Safari 16.4); elsewhere: the beam holds still at its start angle
- Measured cost: 0 B JS (CSS 5.1 kB raw)
- Page: https://moonarc.dev/components/border-beam/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `duration` | `number` | `6` | Seconds per lap. Under 3 reads as a spinner. |
| `length` | `number` | `0.2` | Beam length as a fraction of the perimeter, 0.05–0.5. |
| `width` | `number` | `1.5` | Ring thickness in px. |
| `colors` | `[string, string]` | `amber, violet` | Head and tail colours. |
| `from` | `number` | `0` | Start angle in degrees; offset several cards so they do not sync. |

## Usage

```astro
<div class="relative rounded-2xl border p-6">
  <BorderBeam />
  <h3>Pro</h3>
</div>
```

## Reduced motion

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

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- The ring is a mask, not a border: the beam can fade along its length and the card underneath keeps its own border.
- Linear timing, because it laps forever.
- The beam runs clockwise and the head colour is its leading end, with a hard edge; the tail colour sits 60% of the length behind it and fades to nothing, like a comet.

## Replaces

- BorderBeam (Magic UI)
- MovingBorder (Aceternity)
- StarBorder (React Bits)

## Source

```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>

```
