Text FXAdvanced Text Animationsno dependencies

MarkerHighlight

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 props
0.7
0.4

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add marker-highlight

Writes 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.

example.tsx
1import { MarkerHighlight } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <MarkerHighlight />6  );7}

Props

PropTypeDefault
text

Sentence to render.

string'Great interfaces feel effortless, intentional, and quietly alive.'
highlight

Words or phrases inside text that get a marker stroke (case-insensitive, first match each).

string[]['effortless', 'intentional', 'alive']
color

Marker ink color.

string'#7c3aed'
textColor

Sentence color (defaults to the theme foreground).

stringundefined
thickness

Marker style: a full highlighter band or a low underline stroke.

"full" | "underline"'full'
duration

Duration of each marker sweep in seconds.

number0.7
stagger

Delay between consecutive markers in seconds.

number0.4
delay

Delay before the first marker sweeps, in seconds.

number0.25
ease

GSAP-style ease name (sampled into a CSS linear() easing).

"power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out"'power3.out'
trigger

When the markers sweep in.

"mount" | "inView"'mount'
fontSize

Font size in pixels.

number44
as

Element rendered as the root.

"h1" | "h2" | "h3" | "h4" | "p" | "div"'p'
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

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

components/ui/marker-highlight.tsx
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}