# Tracing Beam (Moonarc)

A line beside an article fills as it is read, with a dot at its head. One registered percentage animates on the article's own view() timeline, and both the fill's height and the head's position read that value. Without scroll timelines the line is full and still. Correct for an article shorter than the viewport too. Zero JavaScript.

- Import: `import TracingBeam from '@moonarc/core/TracingBeam'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/tracing-beam.json`
- Tier A · category scroll · trigger scroll
- Readout: `<TracingBeam color="var(--action)">`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: a full, static line with the dot at its end (Firefox)
- Measured cost: 0 B JS (CSS 5.4 kB raw)
- Page: https://moonarc.dev/components/tracing-beam/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'div'` | Element to render. |
| `color` | `string` | `var(--ma-ink)` | Beam colour. |
| `offset` | `string` | `'2.5rem'` | Width of the gutter the line lives in. |
| `width` | `number` | `2` | Line width in px; the head is five times it. |
| `range` | `string` | `'contain 0% contain 100%'` | animation-range on the article's view timeline. |

## Usage

```astro
<TracingBeam color="var(--ma-ink)" offset="2.5rem">
  <article class="prose">
    <h1>…</h1>
  </article>
</TracingBeam>
```

## Reduced motion

A full line, the dot at its end; nothing tracks the scroll.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- One animated value, two readers: the fill's height and the head's top are both var(--ma-trace-p), so they cannot fall out of step and the head never needs the rail's height.
- The contain range means 0 % when the article's top reaches the top of the viewport and 100 % when its bottom reaches the bottom. The line is the part of the article you have scrolled past. For a short article the same range runs from fully visible at the bottom to fully visible at the top.
- The fill is a gradient from 35 % to full colour, so the beam reads as light travelling down, not a progress bar.
- The property inherits and its initial value is 100 %, so a browser without scroll timelines shows a finished line, never an empty one.

## Replaces

- TracingBeam (Aceternity)
- scroll progress line (Motion Primitives)

## Source

```astro
---
/**
 * TracingBeam — a line beside an article fills as it is read, a dot at its
 * head. Zero JS: one registered percentage animates on the article's own
 * view() timeline over its `contain` range, and both the fill's height and
 * the head's position read that one value. Without scroll timelines, and
 * under reduced motion, the line is full and still. Correct for an article
 * shorter than the viewport too: the range then runs from fully visible at
 * the bottom to fully visible at the top.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  as?: HTMLTag;
  /** Beam colour. */
  color?: string;
  /** Gutter the line lives in, any CSS length. */
  offset?: string;
  /** Line width in px. */
  width?: number;
  /** animation-range on the article's view timeline. */
  range?: string;
}

const { as: Tag = 'div', color = 'var(--ma-ink)', offset = '2.5rem', width = 2, range = 'contain 0% contain 100%', class: className, style, ...rest } = Astro.props;
const vars = [`--ma-trace-c:${color}`, `--ma-trace-gutter:${offset}`, `--ma-trace-w:${width}px`, `--ma-trace-range:${range}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-trace', className]} style={vars} {...rest}>
  <div class="ma-trace__rail" aria-hidden="true">
    <span class="ma-trace__line"></span>
    <span class="ma-trace__fill"></span>
    <span class="ma-trace__head"></span>
  </div>
  <div class="ma-trace__body"><slot /></div>
</Tag>

<style is:global>
  @property --ma-trace-p {
    syntax: '<percentage>';
    inherits: true;
    initial-value: 100%;
  }

  @layer components {
    :where(.ma-trace) {
      position: relative;
      display: grid;
      grid-template-columns: var(--ma-trace-gutter, 2.5rem) minmax(0, 1fr);
    }
    :where(.ma-trace__rail) {
      position: relative;
      grid-column: 1;
      grid-row: 1;
    }
    :where(.ma-trace__body) {
      grid-column: 2;
      grid-row: 1;
      min-width: 0;
    }
    :where(.ma-trace__line),
    :where(.ma-trace__fill) {
      position: absolute;
      top: 0;
      left: calc(50% - var(--ma-trace-w, 2px) / 2);
      width: var(--ma-trace-w, 2px);
      border-radius: 999px;
    }
    :where(.ma-trace__line) {
      height: 100%;
      background: var(--ma-edge);
    }
    :where(.ma-trace__fill) {
      height: var(--ma-trace-p, 100%);
      background: linear-gradient(to bottom, color-mix(in srgb, var(--ma-trace-c, var(--ma-ink)) 35%, transparent), var(--ma-trace-c, var(--ma-ink)));
    }
    :where(.ma-trace__head) {
      position: absolute;
      top: var(--ma-trace-p, 100%);
      left: 50%;
      width: calc(var(--ma-trace-w, 2px) * 5);
      height: calc(var(--ma-trace-w, 2px) * 5);
      translate: -50% -50%;
      border-radius: 50%;
      background: var(--ma-trace-c, var(--ma-ink));
      box-shadow: 0 0 0 calc(var(--ma-trace-w, 2px) * 2) color-mix(in srgb, var(--ma-trace-c, var(--ma-ink)) 22%, transparent);
    }
    @supports (animation-timeline: view()) {
      :where(.ma-trace) {
        animation: ma-trace linear both;
        animation-range: var(--ma-trace-range, contain 0% contain 100%);
      }
      /* the timeline in its own rule, so a minifier cannot fold it into the shorthand */
      :where(html) :where(.ma-trace) {
        animation-timeline: view();
      }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-trace) {
        animation: none;
      }
    }
  }

  @keyframes ma-trace {
    from {
      --ma-trace-p: 0%;
    }
    to {
      --ma-trace-p: 100%;
    }
  }
</style>

```
