Domphy

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.

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.

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 { 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 `500`. */
  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 `5`. */
  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
   * `["left", "right"]` (flanking marks on both sides). */
  brackets?: TextHighlighterBracketSide | TextHighlighterBracketSide[];
  /** `"mount"` (default) draws shortly after mount; `"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 `100`. */
  mountDelay?: number;
  /** `IntersectionObserver` `rootMargin` used when `trigger` is `"view"`. Defaults to `"-50px"`. */
  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;
  const type = props.type ?? "highlight";
  const colorRole = props.color ?? "highlight";
  const tone = props.tone ?? (type === "highlight" ? "shift-3" : "shift-9");
  const strokeWidth = props.strokeWidth ?? 1.5;
  const duration = props.duration ?? 500;
  const iterations = props.iterations ?? 2;
  const padding = props.padding ?? 5;
  const multiline = props.multiline ?? true;
  const brackets = props.brackets ?? (["left", "right"] as TextHighlighterBracketSide[]);
  const trigger = props.trigger ?? "mount";
  const mountDelay = props.mountDelay ?? 100;
  const viewMargin = props.viewMargin ?? "-50px";

  return {
    span: children,
    style: { ...(props.style ?? {}) } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof window === "undefined" || typeof document === "undefined") return;
      const targetElement = node.domElement as HTMLElement | null;
      if (!targetElement) return;

      let colorToken = "currentColor";
      try {
        colorToken = themeColorToken(node, tone, colorRole);
      } catch {
        // Falls back to the text's own current color if the theme lookup fails.
        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;

      // Some environments (older browsers, certain test/DOM-shim runtimes)
      // ship an incomplete SVGGeometryElement (e.g. no `getTotalLength()`),
      // which the draw-in animation depends on. Fail open rather than throw.
      const play = () => {
        try {
          annotation?.show();
        } catch {
          // ignore — see above
        }
      };

      let mountTimer: ReturnType<typeof setTimeout> | null = null;
      let observer: IntersectionObserver | null = null;

      if (trigger === "view") {
        if (typeof IntersectionObserver !== "function") {
          // No IntersectionObserver support — fail open and draw immediately
          // rather than never playing.
          play();
        } else {
          observer = new IntersectionObserver(
            (entries) => {
              if (entries.some((entry) => entry.isIntersecting)) {
                play();
                observer?.disconnect();
                observer = null;
              }
            },
            { rootMargin: viewMargin },
          );
          observer.observe(targetElement);
        }
      } else {
        mountTimer = setTimeout(play, mountDelay);
      }

      node.addHook("Remove", () => {
        if (mountTimer) clearTimeout(mountTimer);
        observer?.disconnect();
        try {
          annotation?.remove();
        } catch {
          // ignore
        }
      });
    },
  } as DomphyElement<"span">;
}

export { textHighlighter };

← Back to Magic UI catalog