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 8.3 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. `shape` draws a hand-drawn mark instead: a circle or a box
 * round the phrase, a squiggle or an arrow under it, each an SVG stroke
 * that draws itself (keep those phrases short: the mark takes the phrase's
 * first line). 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;
  /** The mark: a highlighter (the default), or a stroke drawn round or under the phrase. */
  shape?: 'highlight' | 'circle' | 'box' | 'squiggle' | 'arrow';
}

const { text, as = 'p', color, duration, delay, once, threshold, shape = 'highlight', class: className, style, ...rest } = Astro.props;
// a highlighter is translucent so the text reads through it; a stroke beside the text is the emphasis colour, solid
const ink = color ?? (shape === 'highlight' ? 'color-mix(in oklch, currentColor 22%, transparent)' : 'var(--ma-em, currentColor)');
/** Each shape in a 100 by 30 box stretched over the phrase (round it) or under it (a strip at its foot). */
const paths: Record<Exclude<typeof shape, 'highlight'>, string> = {
  circle: 'M52 3C22 2 4 8 4 15c0 8 22 13 48 12 26-1 45-6 44-13C95 6 72 2 40 4',
  box: 'M4 4.5 96.5 3 95.5 27 3.5 26.5 4.5 3',
  squiggle: 'M2 20c6-8 10 8 16 0s10 8 16 0 10 8 16 0 10 8 16 0 10 8 16 0 10 8 16 0',
  arrow: 'M3 20C30 12 62 12 92 18M84 12l9 6-10 4',
};
const parts = text.split(/\*\*([^*]+)\*\*/);
let mark = 0;
const vars = [`--ma-marker-color:${ink}`, 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} data-shape={shape === 'highlight' ? undefined : shape} {...rest}>
  {parts.map((part, i) =>
    i % 2 ? (
      <mark class="ma-marker__mark" style={`--ma-marker-i:${mark++}`}>
        {part}
        {shape !== 'highlight' && <svg class="ma-marker__shape" viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden="true"><path d={paths[shape]} pathLength="1" /></svg>}
      </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;
    }
    /* a drawn mark: no fill behind the phrase, the stroke round it or under it drawing itself on the same delay */
    :where(.ma-marker[data-shape] .ma-marker__mark) { position: relative; background: none; padding: 0; margin: 0; border-radius: 0; }
    :where(.ma-marker__shape) { position: absolute; inset: -0.3em -0.45em; inline-size: calc(100% + 0.9em); block-size: calc(100% + 0.6em); overflow: visible; pointer-events: none; }
    :where(.ma-marker:is([data-shape='squiggle'], [data-shape='arrow']) .ma-marker__shape) { inset: auto -0.1em -0.42em; inline-size: calc(100% + 0.2em); block-size: 0.55em; }
    :where(.ma-marker__shape path) { fill: none; stroke: var(--ma-marker-color); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 0; transition: stroke-dashoffset var(--ma-marker-dur, var(--ma-duration-slow, 600ms)) var(--ma-ease-out) calc(var(--ma-r-delay, 0ms) + var(--ma-marker-i, 0) * var(--ma-marker-dur, var(--ma-duration-slow, 600ms))); }
    :where([data-ma-js] .ma-marker:not(.ma-in) .ma-marker__shape path) { stroke-dashoffset: 1; transition: none; }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-marker__mark) {
        transition: none;
      }
      :where(.ma-marker__shape path) { 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%; the emphasis colour for a drawn shapeThe highlight's colour, translucent so the text stays legible in both themes; for a drawn shape, the stroke's.
shape'highlight' | 'circle' | 'box' | 'squiggle' | 'arrow''highlight'A highlighter behind the phrase, or a hand-drawn stroke: a circle or a box round it, a squiggle or an arrow under it, drawing itself on the same delay. Keep a drawn phrase to one line.
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