An editorial section on ruled paper where three phrases are inked with a highlighter as the block scrolls into view. Each stroke is a background gradient that grows behind the words, so the text itself never moves and the highlight wraps correctly over several lines. The first stroke runs left to right, the second right to left and the third rises from the baseline, one after another. A button replays the sequence. On phones the headline drops to a smaller size and the strokes simply follow the new line breaks.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/text/text-highlighter)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { motion, useInView, useReducedMotion } from "framer-motion";
type Direction = "ltr" | "rtl" | "btt";
// Marker colours. Change these three values to re-ink the whole page.
const INK = { lemon: "#fde047", mint: "#86efac", coral: "#fda4af" };
// Each highlight waits for the one before it: STEP seconds apart, each taking SWEEP seconds.
const STEP = 0.55;
const SWEEP = 0.9;
const notes = [
{ label: "Paper", value: "120 gsm, fountain pen safe" },
{ label: "Binding", value: "Lay-flat thread sewn" },
{ label: "Made in", value: "A small press in Hallow Bay" },
];
function Highlight({ children, color, order, direction = "ltr", run, instant }: { children: ReactNode; color: string; order: number; direction?: Direction; run: boolean; instant: boolean }) {
const from = direction === "btt" ? "100% 0%" : "0% 100%";
const position = direction === "rtl" ? "100% 0%" : direction === "btt" ? "0% 100%" : "0% 0%";
const style: CSSProperties = {
backgroundImage: `linear-gradient(${color}, ${color})`,
backgroundRepeat: "no-repeat",
backgroundPosition: position,
// Keeps the rounded marker ends on every line when the phrase wraps.
boxDecorationBreak: "clone",
WebkitBoxDecorationBreak: "clone",
};
return (
<motion.mark
className="rounded-[0.2em] bg-transparent px-[0.18em] py-[0.04em] text-inherit"
style={style}
initial={{ backgroundSize: instant ? "100% 100%" : from }}
animate={{ backgroundSize: run || instant ? "100% 100%" : from }}
transition={instant ? { duration: 0 } : { type: "spring", duration: SWEEP, bounce: 0, delay: run ? order * STEP : 0 }}
>
{children}
</motion.mark>
);
}
export default function MarkerHighlightTextThatSweepsAcrossPhrases() {
const reduce = useReducedMotion();
const ref = useRef<HTMLElement>(null);
const inView = useInView(ref, { once: true, amount: 0.3 });
const [inked, setInked] = useState(true);
const run = inView && inked;
const instant = !!reduce;
const replay = () => {
setInked(false);
window.setTimeout(() => setInked(true), 450);
};
return (
<section ref={ref} className="relative overflow-hidden bg-[#faf6ec] px-6 py-20 text-stone-900 sm:py-24">
{/* Ruled paper lines */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-60"
style={{ backgroundImage: "linear-gradient(transparent 39px, rgba(120,113,108,0.16) 40px)", backgroundSize: "100% 40px" }}
/>
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0 hidden w-px bg-rose-300/70 sm:block sm:left-[max(1.5rem,calc(50%-30rem))]" />
<div className="relative mx-auto max-w-4xl sm:pl-12">
<p className="font-mono text-xs uppercase tracking-[0.25em] text-stone-600">Larkfield Paper Co. / Field notes no. 14</p>
<h2 className="mt-6 font-serif text-4xl leading-[1.25] tracking-tight sm:text-6xl sm:leading-[1.2]">
A notebook for people who{" "}
<Highlight color={INK.lemon} order={0} run={run} instant={instant}>
think with a pen
</Highlight>{" "}
and plan on paper.
</h2>
<p className="mt-8 max-w-2xl text-lg leading-[1.9] text-stone-700 sm:text-xl sm:leading-[1.9]">
Every Larkfield book opens flat on the desk, so you can write right up to the spine.{" "}
<Highlight color={INK.mint} order={1} direction="rtl" run={run} instant={instant}>
The paper takes ink without bleeding through
</Highlight>
, the dot grid stays out of the way, and the cover softens with use.{" "}
<Highlight color={INK.coral} order={2} direction="btt" run={run} instant={instant}>
Fill one and we will recycle it and send the next at half price.
</Highlight>
</p>
<div className="mt-10 flex flex-wrap items-center gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-stone-900 px-7 text-sm font-semibold text-white transition hover:bg-stone-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#faf6ec]">
Shop notebooks
</a>
<button
type="button"
onClick={replay}
className="inline-flex h-12 items-center gap-2 rounded-full border border-stone-400 px-6 text-sm font-semibold text-stone-800 transition hover:border-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-[#faf6ec]"
>
<svg aria-hidden="true" viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 3.5l3 3L8 15l-4 1 1-4 8.5-8.5z" />
<path d="M11.5 5.5l3 3" />
</svg>
Highlight again
</button>
</div>
<dl className="mt-12 grid gap-6 border-t border-stone-300 pt-8 sm:grid-cols-3">
{notes.map((n) => (
<div key={n.label}>
<dt className="font-mono text-xs uppercase tracking-widest text-stone-600">{n.label}</dt>
<dd className="mt-1 font-serif text-lg text-stone-900">{n.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/marker-highlight-text-that-sweeps-across-phrases.json
Each phrase has a one-colour `linear-gradient` as its background image. The animation changes `background-size` from zero width to full width, which looks like a marker being dragged across. Nothing is positioned absolutely, so the stroke always matches the text.
The `mark` is an inline element with `box-decoration-break: clone`. The browser then paints the background, padding and rounded corners separately for every line fragment, so each line gets its own complete stroke.
Give `Highlight` its own `useState` flag, set it in `onMouseEnter`, `onMouseLeave`, `onFocus` and `onBlur`, and use that flag in place of the `run` prop. Keep the focus handlers so keyboard users get the same effect.
Yes, but long strokes read better when they are slower. Raise `SWEEP` to about 1.6 seconds for a full paragraph, and consider highlighting only the sentence that matters.
It repaints the phrase rather than running on the compositor, but the painted area is a few lines of text and each stroke runs once for under a second. It is not a continuous loop, so the cost is negligible.
Adapted from Text Highlighter in Fancy Components, used under its open-source licence. Licences and credits
Related: Word by Word Blur In Paragraph Reveal, Text Scramble Decode Headline, Editorial Hero with Sliding Letters and Drawn Underline.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.