Sentence in which chosen words or phrases receive hand-drawn marker strokes that sweep in one after another, each slightly skewed and rotated with organic rounded ends, as a full highlighter band or a low underline.
Preview
Great interfaces feel effortless, intentional, and quietly alive.
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add marker-highlightWrites components/ui/marker-highlight.tsx and components/styles/marker-highlight.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { MarkerHighlight } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <MarkerHighlight />6 );7}Props
Accessibility
The sentence is real text and the highlighted phrases are wrapped in semantic <mark> elements; the ink strokes are aria-hidden decoration. Adjacent punctuation stays glued to the marked phrase so it never wraps alone. Under prefers-reduced-motion the markers are shown fully drawn with no transition.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/marker-highlight.module.css";5 6export type MarkerHighlightEase =7 "power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out";8 9export interface MarkerHighlightProps {10 /** Sentence to render */11 text?: string;12 /** Words or phrases inside `text` that get a marker stroke (case-insensitive, first match each) */13 highlight?: string[];14 /** Marker ink color */15 color?: string;16 /** Sentence color (defaults to the theme foreground) */17 textColor?: string;18 /** Marker style: a full highlighter band or a low underline stroke */19 thickness?: "full" | "underline";20 /** Duration of each marker sweep in seconds */21 duration?: number;22 /** Delay between consecutive markers in seconds */23 stagger?: number;24 /** Delay before the first marker sweeps, in seconds */25 delay?: number;26 /** GSAP-style ease name */27 ease?: MarkerHighlightEase;28 /** When the markers sweep in */29 trigger?: "mount" | "inView";30 /** Font size in pixels */31 fontSize?: number;32 /** Element rendered as the root */33 as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";34 /** Optional className merged onto the root */35 className?: string;36 /** Optional inline styles merged onto the root */37 style?: React.CSSProperties;38}39 40const EASES: Record<MarkerHighlightEase, (t: number) => number> = {41 "power2.out": (t) => 1 - Math.pow(1 - t, 3),42 "power3.out": (t) => 1 - Math.pow(1 - t, 4),43 "power4.out": (t) => 1 - Math.pow(1 - t, 5),44 "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),45 "back.out": (t) => {46 const c = 1.70158;47 const u = t - 1;48 return 1 + (c + 1) * u * u * u + c * u * u;49 },50 "elastic.out": (t) =>51 t <= 0 ? 0 : t >= 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,52 "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,53 "circ.out": (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),54};55 56/** Samples an ease into a CSS linear() timing function (falls back to the CSS default where unsupported). */57const toCssEasing = (name: MarkerHighlightEase) => {58 const fn = EASES[name] ?? EASES["power3.out"];59 const pts: string[] = [];60 for (let i = 0; i <= 48; i++) pts.push(String(Math.round(fn(i / 48) * 10000) / 10000));61 return `linear(${pts.join(", ")})`;62};63 64const rand = (n: number) => {65 const x = Math.sin(n * 9301 + 49297) * 233280;66 return x - Math.floor(x);67};68 69interface Segment {70 text: string;71 mark: number;72 /** Punctuation glued before the marked phrase so it never wraps away from it */73 lead: string;74 /** Punctuation glued after the marked phrase */75 tail: string;76}77 78function segment(text: string, phrases: string[]): Segment[] {79 const lower = text.toLowerCase();80 const ranges: Array<[number, number]> = [];81 phrases82 .map((p) => p.trim())83 .filter(Boolean)84 .forEach((p) => {85 const at = lower.indexOf(p.toLowerCase());86 if (at === -1) return;87 const end = at + p.length;88 if (ranges.some(([s, e]) => at < e && end > s)) return;89 ranges.push([at, end]);90 });91 ranges.sort((a, b) => a[0] - b[0]);92 const out: Segment[] = [];93 let cursor = 0;94 ranges.forEach(([s, e], i) => {95 if (s > cursor) out.push({ text: text.slice(cursor, s), mark: -1, lead: "", tail: "" });96 out.push({ text: text.slice(s, e), mark: i, lead: "", tail: "" });97 cursor = e;98 });99 if (cursor < text.length) out.push({ text: text.slice(cursor), mark: -1, lead: "", tail: "" });100 out.forEach((seg, i) => {101 if (seg.mark === -1) return;102 const prev = out[i - 1];103 const next = out[i + 1];104 if (prev && prev.mark === -1) {105 const m = /\S+$/.exec(prev.text);106 if (m) {107 seg.lead = m[0];108 prev.text = prev.text.slice(0, m.index);109 }110 }111 if (next && next.mark === -1) {112 const m = /^\S+/.exec(next.text);113 if (m) {114 seg.tail = m[0];115 next.text = next.text.slice(m[0].length);116 }117 }118 });119 return out;120}121 122const DEFAULT_HIGHLIGHT = ["effortless", "intentional", "alive"];123 124export function MarkerHighlight({125 text = "Great interfaces feel effortless, intentional, and quietly alive.",126 highlight = DEFAULT_HIGHLIGHT,127 color = "#7c3aed",128 textColor,129 thickness = "full",130 duration = 0.7,131 stagger = 0.4,132 delay = 0.25,133 ease = "power3.out",134 trigger = "mount",135 fontSize = 44,136 as = "p",137 className = "",138 style,139}: MarkerHighlightProps) {140 const rootRef = React.useRef<HTMLElement | null>(null);141 const [active, setActive] = React.useState(false);142 const highlightKey = highlight.join("\u0000");143 const segments = React.useMemo(() => segment(text, highlightKey.split("\u0000")), [text, highlightKey]);144 145 React.useEffect(() => {146 const root = rootRef.current;147 setActive(false);148 if (!root) return;149 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {150 setActive(true);151 return;152 }153 let raf = 0;154 const activate = () => {155 // Two frames so the collapsed state is painted before the sweep starts.156 raf = requestAnimationFrame(() => {157 raf = requestAnimationFrame(() => setActive(true));158 });159 };160 let io: IntersectionObserver | null = null;161 if (trigger === "inView" && typeof IntersectionObserver !== "undefined") {162 io = new IntersectionObserver(163 (entries) => {164 if (entries.some((e) => e.isIntersecting)) {165 io?.disconnect();166 activate();167 }168 },169 { threshold: 0.4 },170 );171 io.observe(root);172 } else {173 activate();174 }175 return () => {176 cancelAnimationFrame(raf);177 io?.disconnect();178 };179 }, [trigger, text, highlightKey]);180 181 const Tag = as;182 const vars = {183 "--mf-mh-size": `${fontSize}px`,184 "--mf-mh-color": color,185 "--mf-mh-duration": `${Math.max(0, duration)}s`,186 "--mf-mh-ease": toCssEasing(ease),187 ...(textColor ? { "--mf-mh-text": textColor } : {}),188 ...style,189 } as React.CSSProperties;190 191 return (192 <Tag193 ref={(el: HTMLElement | null) => {194 rootRef.current = el;195 }}196 className={`${styles.root} ${className}`.trim()}197 data-active={active ? "true" : undefined}198 data-thickness={thickness}199 style={vars}200 >201 {segments.map((seg, i) =>202 seg.mark === -1 ? (203 <React.Fragment key={i}>{seg.text}</React.Fragment>204 ) : (205 <span key={i} className={styles.nowrap}>206 {seg.lead}207 <mark208 className={styles.mark}209 style={210 {211 "--mf-mh-delay": `${(delay + seg.mark * stagger).toFixed(3)}s`,212 "--mf-mh-rot": `${((rand(seg.mark + 3) - 0.5) * 3).toFixed(2)}deg`,213 "--mf-mh-skew": `${(-4 - rand(seg.mark + 11) * 6).toFixed(2)}deg`,214 } as React.CSSProperties215 }216 >217 <span aria-hidden="true" className={styles.ink} />218 <span className={styles.label}>{seg.text}</span>219 </mark>220 {seg.tail}221 </span>222 ),223 )}224 </Tag>225 );226}