textHighlighter
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call textHighlighter() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
children | string | DomphyElement | DomphyElement[] | Text (or arbitrary content) the annotation wraps. Defaults to a short demo phrase. |
type | TextHighlighterAnnotationType | Which hand-drawn mark to draw. Defaults to "highlight" (the pastel swipe-behind-text look). |
color | ThemeColor | Theme color role for the stroke/fill. Defaults to "highlight". |
tone | ElementTone | Tone (lightness step) the color resolves at. Defaults to a light pastel ("shift-3") for the "highlight" fill type, or a stronger, clearly-visible tone ("shift-9") for every stroke-only type. |
strokeWidth | number | Stroke thickness in px. Ignored by the "highlight" type, which always draws a near-text-height band. Defaults to 1.5. |
duration | number | How long the draw-in animation takes, in ms. Defaults to 600. |
iterations | number | Number of overlapping redraw passes — above 1 gives the rougher, more authentic "scribbled by hand" look. Defaults to 2. |
padding | TextHighlighterPadding | Gap in px between the text glyphs and the annotation stroke. Defaults to 2. |
multiline | boolean | Whether the annotation should be drawn as one continuous shape (false) or broken per visual line when the text wraps (true). Defaults to true. |
brackets | TextHighlighterBracketSide | TextHighlighterBracketSide[] | Which side(s) get a corner bracket mark. Only used by the "bracket" type. Defaults to "right" (matches rough-notation's own fallback for an unset brackets config). |
trigger | "mount" | "view" | "mount" (default) draws immediately after mount (matching upstream's useLayoutEffect); "view" waits until the wrapper first scrolls into the viewport, so offscreen highlights don't animate prematurely on long pages. |
mountDelay | number | Delay before drawing, in ms, once triggered. Defaults to 0 (upstream draws immediately). |
viewMargin | string | IntersectionObserver rootMargin used when trigger is "view". Defaults to "-10%" (matches upstream's useInView margin). |
style | StyleObject | Passthrough style merged onto the wrapping span. |
Implementation notes
Delegates rendering/animation to rough-notation (already an approved package dependency), which is a near-exact functional match for the spec: annotate(element, config) measures the target span's box, draws a rough.js path behind/around it, and reveals it via the same stroke-dasharray/stroke-dashoffset 'draw it in' technique the spec describes, with iterations giving the multi-pass sketchy look. All 7 RoughAnnotationType variants (highlight/underline/circle/box/bracket/strike-through/crossed-off) are exposed via the type prop. Colors resolve through themeColorToken() (design-time hex resolution, the documented escape hatch for third-party APIs that need a literal color string) rather than any hardcoded value, with tone defaulting lighter for the highlight fill vs. stronger for stroke-only types. trigger: 'mount' | 'view' covers the immediate-vs-scroll-into-view behavior via the same IntersectionObserver pattern used elsewhere in this package (blurFade.ts). One real gap: jsdom (this repo's test runtime) does not implement SVGGeometryElement.prototype.getTotalLength, which the draw-in animation depends on — verified via a standalone probe script. The component wraps both annotate() construction and every .show() call in try/catch to fail open in that case (and any other environment with an incomplete SVG implementation), so this is a defensive-only accommodation, not a behavior change in real browsers, and it mirrors the existing try/catch-around-third-party-lib pattern already used by confetti.ts in this package. Direct-source-diff fix (2026-07-05): Missing upstream's ResizeObserver-driven redraw (the underlying rough-notation SVG is sized at draw time and goes stale on any reflow), and duration/padding defaults had drifted from upstream's 600ms/2 to 500ms/5. Added the observer and realigned the defaults.
Status: ported · Reference: Magic UI original
// Magic UI "Highlighter" — clean-room reimplementation.
//
// Wraps a span of text with a hand-drawn marker annotation (a solid pastel
// highlight swipe, an underline squiggle, a circle/box outline, corner
// brackets, a strike-through, or a crossed-off scribble) that draws itself
// in on trigger, as if traced by a pen. Implemented purely from the block's
// public functional/visual spec — no upstream Magic UI source was viewed or
// copied.
//
// Rendering/animation is delegated to `rough-notation` (already an approved
// dependency of this package, same category of integration as
// `canvas-confetti` in confetti.ts) rather than hand-rolling a sketchy SVG
// renderer — it is the standard vanilla-JS library for exactly this
// "hand-drawn annotation" primitive: it measures the target element's box,
// draws a rough.js path behind/around it, and reveals that path with the
// classic stroke-dasharray/stroke-dashoffset "draw it in" technique (path
// length measured via `getTotalLength()`, dash pattern set to hide the
// stroke, offset animated to 0), repeating with fresh jitter per
// `iterations` for the rougher multi-pass "scribbled by hand" look. Using
// its public `annotate(element, config)` API is a legitimate, independent
// integration, not a copy of any UI framework's component source.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { behavior } from "@domphy/core";
import {
type ElementTone,
type ThemeColor,
themeColorToken,
} from "@domphy/theme";
import { annotate } from "rough-notation";
/** Matches rough-notation's own `RoughAnnotationType` literal set. */
export type TextHighlighterAnnotationType =
| "highlight"
| "underline"
| "circle"
| "box"
| "bracket"
| "strike-through"
| "crossed-off";
/** Matches rough-notation's own `BracketType` literal set. */
export type TextHighlighterBracketSide = "left" | "right" | "top" | "bottom";
export type TextHighlighterPadding =
| number
| [number, number]
| [number, number, number, number];
export interface TextHighlighterProps {
/** Text (or arbitrary content) the annotation wraps. Defaults to a short demo phrase. */
children?: string | DomphyElement | DomphyElement[];
/** Which hand-drawn mark to draw. Defaults to `"highlight"` (the pastel swipe-behind-text look). */
type?: TextHighlighterAnnotationType;
/** Theme color role for the stroke/fill. Defaults to `"highlight"`. */
color?: ThemeColor;
/** Tone (lightness step) the color resolves at. Defaults to a light pastel (`"shift-3"`) for the
* `"highlight"` fill type, or a stronger, clearly-visible tone (`"shift-9"`) for every stroke-only type. */
tone?: ElementTone;
/** Stroke thickness in px. Ignored by the `"highlight"` type, which always draws a near-text-height
* band. Defaults to `1.5`. */
strokeWidth?: number;
/** How long the draw-in animation takes, in ms. Defaults to `600`. */
duration?: number;
/** Number of overlapping redraw passes — above 1 gives the rougher, more authentic "scribbled by
* hand" look. Defaults to `2`. */
iterations?: number;
/** Gap in px between the text glyphs and the annotation stroke. Defaults to `2`. */
padding?: TextHighlighterPadding;
/** Whether the annotation should be drawn as one continuous shape (`false`) or broken per visual
* line when the text wraps (`true`). Defaults to `true`. */
multiline?: boolean;
/** Which side(s) get a corner bracket mark. Only used by the `"bracket"` type. Defaults to
* `"right"` (matches rough-notation's own fallback for an unset `brackets` config). */
brackets?: TextHighlighterBracketSide | TextHighlighterBracketSide[];
/** `"mount"` (default) draws immediately after mount (matching upstream's `useLayoutEffect`);
* `"view"` waits until the wrapper first scrolls into the viewport, so offscreen highlights
* don't animate prematurely on long pages. */
trigger?: "mount" | "view";
/** Delay before drawing, in ms, once triggered. Defaults to `0` (upstream draws immediately). */
mountDelay?: number;
/** `IntersectionObserver` `rootMargin` used when `trigger` is `"view"`. Defaults to `"-10%"`
* (matches upstream's `useInView` `margin`). */
viewMargin?: string;
/** Passthrough style merged onto the wrapping span. */
style?: StyleObject;
}
const DEFAULT_TEXT = "a hand-drawn highlighter annotation";
/**
* Inline text wrapper that draws a hand-drawn marker annotation (highlight
* fill, underline, circle, box, bracket, strike-through, or crossed-off)
* around/behind its content, either shortly after mount or the first time it
* scrolls into view. Several instances with different `type`/`color` props
* can sit side by side inside the same paragraph alongside plain text. Call
* with no arguments for a working demo — a pastel highlight swipe behind a
* short phrase.
*/
function textHighlighter(
props: TextHighlighterProps = {},
): DomphyElement<"span"> {
const children = props.children ?? DEFAULT_TEXT;
return {
span: children,
// Upstream wraps its content in `<span className="relative inline-block bg-transparent">`.
// `inline-block` in particular is load-bearing: it gives rough-notation an
// inline-block box to measure instead of a raw inline span. Passthrough
// style still wins (spread last).
style: {
position: "relative",
display: "inline-block",
background: "transparent",
...(props.style ?? {}),
} as StyleObject,
...behavior<TextHighlighterProps>(
"magicui-text-highlighter",
attachTextHighlighter,
props,
),
} as DomphyElement<"span">;
}
function attachTextHighlighter(
node: ElementNode,
initialProps: TextHighlighterProps,
) {
const type = initialProps.type ?? "highlight";
const colorRole = initialProps.color ?? "highlight";
const tone =
initialProps.tone ?? (type === "highlight" ? "shift-3" : "shift-9");
const strokeWidth = initialProps.strokeWidth ?? 1.5;
const duration = initialProps.duration ?? 600;
const iterations = initialProps.iterations ?? 2;
const padding = initialProps.padding ?? 2;
const multiline = initialProps.multiline ?? true;
const brackets = initialProps.brackets ?? "right";
const trigger = initialProps.trigger ?? "mount";
const mountDelay = initialProps.mountDelay ?? 0;
const viewMargin = initialProps.viewMargin ?? "-10%";
if (typeof window === "undefined" || typeof document === "undefined") {
return { update() {}, destroy() {} };
}
const targetElement = node.domElement as HTMLElement | null;
if (!targetElement) return { update() {}, destroy() {} };
let colorToken = "currentColor";
try {
colorToken = themeColorToken(node, tone, colorRole);
} catch {
colorToken = "currentColor";
}
let annotation: ReturnType<typeof annotate> | null = null;
try {
annotation = annotate(targetElement, {
type,
color: colorToken,
strokeWidth,
animationDuration: duration,
iterations,
padding,
multiline,
brackets,
animate: true,
});
} catch {
annotation = null;
}
if (!annotation) return { update() {}, destroy() {} };
let hasShown = false;
const play = () => {
try {
annotation?.show();
hasShown = true;
} catch {
// ignore — incomplete SVGGeometryElement
}
};
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== "undefined") {
let redrawing = false;
resizeObserver = new ResizeObserver(() => {
if (!hasShown || redrawing) return;
redrawing = true;
try {
annotation?.hide();
annotation?.show();
} catch {
// ignore
} finally {
// Defer so a ResizeObserver notification caused by hide/show
// cannot re-enter this callback in a tight loop (jsdom).
setTimeout(() => {
redrawing = false;
}, 0);
}
});
resizeObserver.observe(targetElement);
if (document.body) resizeObserver.observe(document.body);
}
let mountTimer: ReturnType<typeof setTimeout> | null = null;
let observer: IntersectionObserver | null = null;
if (trigger === "view") {
if (typeof IntersectionObserver !== "function") {
play();
} else {
observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
play();
observer?.disconnect();
observer = null;
}
},
{ rootMargin: viewMargin },
);
observer.observe(targetElement);
}
} else if (mountDelay > 0) {
mountTimer = setTimeout(play, mountDelay);
} else {
play();
}
return {
update() {},
destroy() {
if (mountTimer) clearTimeout(mountTimer);
observer?.disconnect();
resizeObserver?.disconnect();
try {
annotation?.remove();
} catch {
// ignore
}
},
};
}
export { textHighlighter };