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.
Setup — External Scripts
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/gsap.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/ScrollTrigger.min.js"></script><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.1/dist/SplitText.min.js"></script>Code
<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>.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;
}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).