Marker Highlight Text
A felt-tip highlighter stroke with a ragged edge that draws itself behind a phrase when it scrolls into view.
- Category
- Text Effects
- Added
- 2026-09-25
- Deps
- none
- Updated
- 2026-09-25
Opening week · Mon–Sat
Walk-ins welcome until five, and the first coffee is on us. Bring the dog.
Props
"use client";
import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
/**
* MarkerHighlight — a felt-tip stroke drawn behind a few words when they
* scroll into view.
*
* THE STROKE IS AN IMAGE, NOT A BOX. The highlight is an SVG rectangle whose
* edges are pushed about by a turbulence displacement filter, painted as the
* span's background image, so it has the ragged edge and the slightly uneven
* ink of a real marker. A CSS box with a colour does not read as a pen.
*
* IT DRAWS FROM THE LEFT. The reveal is a transition on background-size from
* 0% to 100%, which the browser can animate without touching layout. With
* box-decoration-break: clone, a phrase that wraps gets its own stroke on
* every line, and every line draws at once.
*
* IT WAITS FOR YOU. By default the stroke draws when 60% of the phrase is on
* screen, once. Pass `active` to drive it yourself (a step in a tour, a form
* error, a hover on the sentence). Under prefers-reduced-motion the stroke is
* simply there, with no draw — and that is checked live, so a reader who
* toggles the setting mid-page gets the change without a reload.
*
* Highlighter is a light-ground device: keep the text dark. On a dark theme,
* put the phrase on a paper plate rather than fighting it with blend modes.
*/
const HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
const EASE = "cubic-bezier(0.22, 1, 0.36, 1)";
/** The stroke: a rect with turbulence-displaced edges, stretched to any word length. */
function strokeImage(colour: string, seed: number, opacity: number, tilt: number) {
const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 40" preserveAspectRatio="none">' +
'<filter id="r" x="-6%" y="-25%" width="112%" height="150%">' +
'<feTurbulence type="fractalNoise" baseFrequency="0.03 0.11" numOctaves="2" seed="' +
seed +
'"/>' +
'<feDisplacementMap in="SourceGraphic" scale="8" xChannelSelector="R" yChannelSelector="G"/>' +
"</filter>" +
'<rect x="3" y="5" width="194" height="30" rx="2" fill="' +
colour +
'" fill-opacity="' +
opacity +
'" filter="url(#r)" transform="skewX(' +
tilt +
')"/>' +
"</svg>";
return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")';
}
function subscribeReduced(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
const readReduced = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const readReducedServer = () => false;
export function MarkerHighlight({
children,
colour = "#d6ff00",
opacity = 0.82,
duration = 900,
delay = 0,
active,
once = true,
className = "",
}: {
children: React.ReactNode;
/** Ink colour of the stroke. Hex only. */
colour?: string;
/** 0–1. Real marker ink is translucent; 1 reads as a printed bar. */
opacity?: number;
/** Draw time in ms. */
duration?: number;
/** Delay before the draw starts, in ms — stagger several phrases. */
delay?: number;
/** Drive the stroke yourself. Leave undefined to draw on view. */
active?: boolean;
/** Draw once and stay (default), or undraw when the phrase leaves view. */
once?: boolean;
className?: string;
}) {
const ref = useRef<HTMLSpanElement>(null);
const [seen, setSeen] = useState(false);
const reduced = useSyncExternalStore(subscribeReduced, readReduced, readReducedServer);
// A stable seed per instance, so two phrases on one page do not share the
// same ragged edge. useId is not a number, so it is folded into one.
const id = useId();
let seed = 7;
for (let i = 0; i < id.length; i++) seed = (seed * 31 + id.charCodeAt(i)) % 997;
useEffect(() => {
if (active !== undefined) return;
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
// The last entry is the current one; an observer can batch several.
const hit = entries[entries.length - 1].isIntersecting;
if (hit) {
setSeen(true);
if (once) io.disconnect();
} else if (!once) {
setSeen(false);
}
},
{ threshold: 0.6 },
);
io.observe(el);
return () => io.disconnect();
}, [active, once]);
const on = active ?? seen;
const ink = HEX.test(colour) ? colour : "#d6ff00";
const alpha = Math.min(1, Math.max(0, opacity));
return (
<span
ref={ref}
className={"relative " + className}
data-highlighted={on ? "true" : "false"}
style={{
backgroundImage: strokeImage(ink, seed, alpha, -1.5),
backgroundRepeat: "no-repeat",
backgroundPosition: "left center",
backgroundSize: (on ? 100 : 0) + "% 100%",
transition: reduced ? "none" : "background-size " + duration + "ms " + EASE + " " + delay + "ms",
WebkitBoxDecorationBreak: "clone",
boxDecorationBreak: "clone",
padding: "0.04em 0.14em",
margin: "0 -0.14em",
}}
>
{children}
</span>
);
}
About this text effect
Marking one phrase in a paragraph is the most useful thing a text effect can do for a small business site: "walk-ins welcome", "free parking", "open Sundays". Most implementations paint a coloured box behind the words, and a box reads as a rendering mistake rather than a pen. This one draws a real stroke. The highlight is an SVG rectangle whose edges are pushed about by a turbulence filter, painted as the span's background image, so it carries the uneven edge and translucent ink of a felt tip, and a skew of a degree and a half so it does not sit dead level with the baseline. It draws from the left when three-fifths of the phrase is on screen, by transitioning the background size, which costs no layout work, and a wrapped phrase gets its own stroke on every line. You can also drive it yourself with the active prop for tours and form errors. Reduced motion is honoured live, and since the stroke is a background, the text stays selectable, searchable and readable by assistive technology exactly as before.
Curator’s note
Every site has one line that matters more than the rest. A highlighter is the oldest way to say so, and the whole trick is the ragged edge — a clean rectangle reads as a bug.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Rotating Word Swap Headline
text effectsA headline where one word auto-cycles with a vertical odometer-style swap.
Slice Shear Hover
text effectsDisplay type cut into horizontal bands that fan apart under the pointer.
Weight Wave Text
text effectsPer-character variable font weight that swells around the pointer and drifts when idle.