Skip to content
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.

Props

PropTypeDescription
childrenstring | DomphyElement | DomphyElement[]Text (or arbitrary content) the annotation wraps. Defaults to a short demo phrase.
typeTextHighlighterAnnotationTypeWhich hand-drawn mark to draw. Defaults to "highlight" (the pastel swipe-behind-text look).
colorThemeColorTheme color role for the stroke/fill. Defaults to "highlight".
toneElementToneTone (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.
strokeWidthnumberStroke thickness in px. Ignored by the "highlight" type, which always draws a near-text-height band. Defaults to 1.5.
durationnumberHow long the draw-in animation takes, in ms. Defaults to 600.
iterationsnumberNumber of overlapping redraw passes — above 1 gives the rougher, more authentic "scribbled by hand" look. Defaults to 2.
paddingTextHighlighterPaddingGap in px between the text glyphs and the annotation stroke. Defaults to 2.
multilinebooleanWhether the annotation should be drawn as one continuous shape (false) or broken per visual line when the text wraps (true). Defaults to true.
bracketsTextHighlighterBracketSide | 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.
mountDelaynumberDelay before drawing, in ms, once triggered. Defaults to 0 (upstream draws immediately).
viewMarginstringIntersectionObserver rootMargin used when trigger is "view". Defaults to "-10%" (matches upstream's useInView margin).
styleStyleObjectPassthrough 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 };

← Back to Magic UI catalog