Skip to content

Components / Text

Highlight Marker

A highlighter sweeps across the phrases you marked, one after another, when the sentence scrolls into view. Each phrase is a real <mark> whose background grows from the left; wrapped lines are painted as separate strokes. Composes Reveal; no script of its own.

Live demo

Every cost here is measured from a real build and printed beside the component.

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

Uses Reveal and the shared runtime (1.9 kB raw, once per site). With those included: 3.0 kB raw.

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

Baseline · widely available

every browser.

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 HighlightMarker from '@moonarc/core/HighlightMarker';
---
<HighlightMarker text="Every cost here is **measured from a real build** and printed **beside the component**." />
<HighlightMarker as="h2" text="**Zero bytes** of JavaScript." color="color-mix(in oklch, var(--ma-shine) 60%, transparent)" duration={600} />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Scroll Reveal, the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/highlight-marker.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. If you paste it, also copy Reveal.astro and runtime.ts, and add the JS gate to your head.

HighlightMarker.astro
---
/**
 * HighlightMarker — a highlighter sweeps across phrases, one after another,
 * when the text scrolls into view. Phrases are marked **like this** in the
 * prop and rendered as <mark> elements whose background-size grows from 0
 * to 100% on a per-phrase delay; box-decoration-break keeps the sweep on
 * wrapped lines. Composes Reveal; zero script of its own. The server HTML
 * is the plain sentence with the marks already in place.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';
import Reveal from './Reveal.astro';

interface Props extends HTMLAttributes<'p'> {
  /** The sentence; wrap phrases to highlight in double asterisks. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Highlight colour; a translucent colour keeps the text legible. */
  color?: string;
  /** Sweep duration per phrase in ms. Unset: the preset's --ma-duration. */
  duration?: number;
  /** Delay in ms before the first sweep. */
  delay?: number;
  /** Play once and stay. */
  once?: boolean;
  /** Fraction visible to trigger. */
  threshold?: number;
}

const { text, as = 'p', color = 'color-mix(in oklch, currentColor 22%, transparent)', duration, delay, once, threshold, class: className, style, ...rest } = Astro.props;
const parts = text.split(/\*\*([^*]+)\*\*/);
let mark = 0;
const vars = [`--ma-marker-color:${color}`, duration !== undefined && `--ma-marker-dur:${duration}ms`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Reveal as={as} from="none" delay={delay} once={once} threshold={threshold} class={['ma-marker', className].filter(Boolean).join(' ')} style={vars} {...rest}>
  {parts.map((part, i) => (i % 2 ? <mark class="ma-marker__mark" style={`--ma-marker-i:${mark++}`}>{part}</mark> : part))}
</Reveal>

<style is:global>
  @layer components {
    :where(.ma-marker__mark) {
      color: inherit;
      background-color: transparent;
      background-image: linear-gradient(var(--ma-marker-color), var(--ma-marker-color));
      background-repeat: no-repeat;
      background-position: 0 0;
      background-size: 100% 100%;
      padding: 0.08em 0.18em;
      margin-inline: -0.18em;
      border-radius: 0.25em;
      -webkit-box-decoration-break: clone;
      box-decoration-break: clone;
      /* each phrase waits for the one before it; the delay is the one Reveal writes on this root, so the sweep keeps
         step with the sentence's fade */
      transition: background-size var(--ma-marker-dur, var(--ma-duration)) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-marker-i, 0) * var(--ma-marker-dur, var(--ma-duration)));
    }
    :where([data-ma-js] .ma-marker:not(.ma-in) .ma-marker__mark) {
      background-size: 0% 100%;
      transition: none;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-marker__mark) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
textstringnoneThe sentence. Wrap each phrase to highlight in double asterisks; they sweep in order.
asHTMLTag'p'Element to render.
colorstringcurrentColor at 22%Highlight colour. Keep it translucent so the text stays legible in both themes.
durationnumberpreset (ui: 450)Sweep duration per phrase in ms; the next phrase starts when the previous lands.
delaynumber0Delay before the first sweep.
oncebooleantruePlay once and stay.
thresholdnumber0.2Fraction visible to trigger.

Reduced motion

The phrases are highlighted at once.

With ClientRouter

Inherits Reveal: rebinds after every navigation.

Why it is built this way

Replaces: Highlighter (Magic UI) · Highlight (Aceternity hero highlight). See the migration table.

A highlight sweeps across the text every few seconds.

every browseralways

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

A link underline that enters from the left on hover and leaves to the right, so the exit continues the entrance instead of rewinding it.

every browserhover

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