Highlight Marker Text Reveal

Scroll-triggered text reveal where each line of text is covered by a colored bar that scales away as it enters the viewport, mimicking the look of a highlight marker being drawn across the text.

GSAPScrollTriggerSplitTextText RevealScroll

Setup — External Scripts

GSAP CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script>
ScrollTrigger CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script>
SplitText CDN
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>

Code

index.html
html
<h1 data-highlight-marker-reveal data-marker-direction="right" data-marker-theme="pink" class="highlight-title">Here's a text reveal that looks like a highlight marker</h1>
styles.css
css
.highlight-title {
  text-align: center;
  letter-spacing: -0.03em;
  text-transform: uppercase;
  margin-top: 0;
  margin-bottom: 0;
  font-family: Haffer, Arial, sans-serif;
  font-size: 6vw;
  font-weight: 900;
  line-height: 0.9;
}

[data-highlight-marker-reveal] {
  visibility: hidden;
}

[data-highlight-marker-reveal] .highlight-marker-line {
  width: auto;
  display: inline-block !important;
  margin: -0.055em 0px;
}

.highlight-marker-bar {
  position: absolute;
  inset: -0.055em 0px;
  z-index: 1;
  pointer-events: none;
}
script.js
javascript
function initHighlightMarkerTextReveal() {
  const defaults = {
    direction: "right",
    theme: "pink",
    scrollStart: "top 90%",
    staggerStart: "start",
    stagger: 100,
    barDuration: 0.6,
    barEase: "power3.inOut",
  };

  const colorMap = {
    pink: "#C700EF",
    white: "#FFFFFF",
  };

  const directionMap = {
    right: { prop: "scaleX", origin: "right center" },
    left:  { prop: "scaleX", origin: "left center" },
    up:    { prop: "scaleY", origin: "center top" },
    down:  { prop: "scaleY", origin: "center bottom" },
  };

  function resolveColor(value) {
    if (colorMap[value]) return colorMap[value];
    if (value.startsWith("--")) return getComputedStyle(document.body).getPropertyValue(value).trim() || value;
    return value;
  }

  function createBar(color, origin) {
    const bar = document.createElement("div");
    bar.className = "highlight-marker-bar";
    Object.assign(bar.style, { backgroundColor: color, transformOrigin: origin });
    return bar;
  }

  function cleanupElement(el) {
    if (!el._highlightMarkerReveal) return;
    el._highlightMarkerReveal.timeline?.kill();
    el._highlightMarkerReveal.scrollTrigger?.kill();
    el._highlightMarkerReveal.split?.revert();
    el.querySelectorAll(".highlight-marker-bar").forEach(bar => bar.remove());
    delete el._highlightMarkerReveal;
  }

  let reduceMotion = false;
  gsap.matchMedia().add({ reduce: "(prefers-reduced-motion: reduce)" }, (context) => {
    reduceMotion = context.conditions.reduce;
  });

  if (reduceMotion) {
    document.querySelectorAll("[data-highlight-marker-reveal]").forEach(el => gsap.set(el, { autoAlpha: 1 }));
    return;
  }

  document.querySelectorAll("[data-highlight-marker-reveal]").forEach(cleanupElement);

  const elements = document.querySelectorAll("[data-highlight-marker-reveal]");
  if (!elements.length) return;

  elements.forEach(el => {
    const direction     = el.getAttribute("data-marker-direction")    || defaults.direction;
    const theme         = el.getAttribute("data-marker-theme")        || defaults.theme;
    const scrollStart   = el.getAttribute("data-marker-scroll-start") || defaults.scrollStart;
    const staggerStart  = el.getAttribute("data-marker-stagger-start")|| defaults.staggerStart;
    const staggerOffset = (parseFloat(el.getAttribute("data-marker-stagger")) || defaults.stagger) / 1000;

    const color     = resolveColor(theme);
    const dirConfig = directionMap[direction] || directionMap.right;

    el._highlightMarkerReveal = {};

    const split = SplitText.create(el, {
      type: "lines",
      linesClass: "highlight-marker-line",
      autoSplit: true,
      onSplit(self) {
        const instance = el._highlightMarkerReveal;
        instance.timeline?.kill();
        instance.scrollTrigger?.kill();
        el.querySelectorAll(".highlight-marker-bar").forEach(bar => bar.remove());

        const lines = self.lines;
        const tl = gsap.timeline({ paused: true });

        lines.forEach((line, i) => {
          gsap.set(line, { position: "relative", overflow: "hidden" });
          const bar = createBar(color, dirConfig.origin);
          line.appendChild(bar);
          const staggerIndex = staggerStart === "end" ? lines.length - 1 - i : i;
          tl.to(bar, { [dirConfig.prop]: 0, duration: defaults.barDuration, ease: defaults.barEase }, staggerIndex * staggerOffset);
        });

        gsap.set(el, { autoAlpha: 1 });

        const st = ScrollTrigger.create({
          trigger: el,
          start: scrollStart,
          once: true,
          onEnter: () => tl.play(),
        });

        instance.timeline = tl;
        instance.scrollTrigger = st;
      },
    });

    el._highlightMarkerReveal.split = split;
  });
}

document.addEventListener("DOMContentLoaded", () => {
  document.fonts.ready.then(() => {
    initHighlightMarkerTextReveal();
  });
});

Notes

  • The element is initially set to `visibility: hidden` via CSS and only made visible once bars are built, preventing a flash of un-highlighted text.
  • SplitText `autoSplit: true` re-runs the split automatically on resize, and the `onSplit` callback rebuilds all bars and kills any previous timelines to stay in sync.
  • `barDuration` and `barEase` are only configurable through the `defaults` object — they intentionally have no per-element attributes to keep the look consistent across the page.
  • If `prefers-reduced-motion` is active, all elements are made visible immediately with no animation.
  • Calling `initHighlightMarkerTextReveal()` again (e.g. after a Barba.js transition) cleanly tears down previous splits and ScrollTrigger instances before reinitialising.

Guide

Direction

Control which way the bar moves with `data-marker-direction` (default `right`). Accepted values: `left`, `right`, `up`, `down` — the bar anchors to that edge and scales toward it, revealing text from the opposite side.

Theme / Color

Set the bar color via `data-marker-theme`. Accepts a named key from `colorMap` (e.g. `pink`), a CSS custom property (e.g. `--brand-accent`), or any raw CSS color value (`#ff6600`, `rgb(...)`). Add more entries to `colorMap` to create named color presets.

Bar height and spacing

The visual overlap between bars is controlled purely through CSS. The negative `margin` on `.highlight-marker-line` and the negative `inset` on `.highlight-marker-bar` extend bars beyond the line box. Increase these values for bars that bleed into adjacent lines; reduce them for visible gaps.

Stagger order

By default lines reveal top-to-bottom. Add `data-marker-stagger-start="end"` to reverse the sequence. Adjust the delay between lines with `data-marker-stagger` (in milliseconds, default 100).