Skip to content

Components / Scroll

Tracing Beam

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.

Live demo

scroll inside

The line is the part you have read.

One registered percentage runs on the article's own view timeline; the fill's height and the dot's position both read it, so the two cannot drift apart.

The range is contain: 0 % when the top of this article meets the top of the box, 100 % when its end meets the bottom.

Without scroll timelines the line is full and still, which is the honest state: a finished article.

The fill is a gradient from a third of the colour to all of it, so the beam reads as light travelling down rather than a progress bar.

The head is five line-widths across with a soft ring; it sits exactly where the fill ends because both read the same value.

The end.

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.4 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 115 · not Firefox · Safari 26; needs scroll-timeline, property. Elsewhere: a full, static line with the dot at its end (Firefox).

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 TracingBeam from '@moonarc/core/TracingBeam';
---
<TracingBeam color="var(--ma-ink)" offset="2.5rem">
  <article class="prose">
    <h1>…</h1>
  </article>
</TracingBeam>
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/tracing-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.

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

Props

PropTypeDefaultDescription
asHTMLTag'div'Element to render.
colorstringvar(--ma-ink)Beam colour.
offsetstring'2.5rem'Width of the gutter the line lives in.
widthnumber2Line width in px; the head is five times it.
rangestring'contain 0% contain 100%'animation-range on the article's view timeline.

Reduced motion

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

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: TracingBeam (Aceternity) · scroll progress line (Motion Primitives). See the migration table.

A table of contents that lights up as you read.

every browserscroll

A hairline along the top of the viewport that fills as the page is read.

Chrome 115 · not Firefox · Safari 26scroll

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

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