Domphy

pointerHighlight

A Effects 3D block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call pointerHighlight() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full behavior implemented: an IntersectionObserver (guarded for non-browser runtimes) flips a State<boolean> once the wrapped phrase scrolls into view (rootMargin configurable), driving a one-shot (or repeatable via once=false) reveal. The rectangle 'draws itself' via the pathLength=100 + stroke-dasharray/dashoffset SVG line-draw trick (same technique this package's own borderBeam.ts/shineBorder.ts use for their orbiting comets) rather than a plain fade, so it visually reads as an outline being traced. The pointer glyph is a small hand-authored solid-fill SVG cursor arrow, staggered in after the rectangle via transition.delay. Both are driven by motion() (Web Animations API), matching blurFade.ts's 'toState + motion(), flipped once by an observer' idiom. Found and fixed a real core gap while building this: pathLength was missing from packages/core/src/constants/CamelAttributes.ts, so it was being kebab-cased to the invalid path-length attribute instead of staying pathLength — fixed at the source (additive-only), same precedent as squigglyText.ts's SvgTags.ts fix.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "Pointer Highlight" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// short inline phrase that, the first time it scrolls into the viewport, gets
// a hand-off animated rectangle outline drawn around it plus a small
// mouse-cursor glyph that pops in beside one corner — a scroll-triggered,
// one-shot "highlighter annotation", not a hover/cursor-tracking effect (per
// the task's own researchNote: an initial cursor-tracking read of the source
// was inaccurate — the real trigger is `useInView`, so this ports the
// documented in-view behavior, not pointer tracking).
//
// The rectangle "draws itself" via the classic SVG line-draw trick — a
// `pathLength="100"`-normalized `stroke-dasharray`/`stroke-dashoffset` pair
// animated from fully offset (100) to zero via `motion()` (Web Animations
// API) — rather than a plain opacity/scale fade, so it visually reads as an
// outline being traced, matching the spec's "animates in... so it looks like
// it is being outlined" wording. `pathLength="100"` (the same technique this
// package's own `borderBeam.ts`/`shineBorder.ts` use for their orbiting
// comets) normalizes the rect's length to exactly 100 units regardless of
// its actual rendered perimeter, so the dash values never need a
// `ResizeObserver`/layout measurement to look correct at any text size. The
// pointer glyph is a small hand-authored solid-fill SVG cursor arrow (not
// traced from any icon library), faded + slid in from a short offset,
// staggered slightly after the rectangle via a longer `transition.delay`.
//
// Both reveals are driven by a plain `State<MotionKeyframe>` written once
// inside an `IntersectionObserver` callback — the same "toState + motion(),
// flipped once by an observer" idiom this package's own `blurFade.ts` uses
// for its `trigger: "view"` mode.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { type MotionKeyframe, motion, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

export type PointerHighlightCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";

export interface PointerHighlightProps {
  /** The highlighted phrase itself. Defaults to a short demo phrase. */
  children?: DomphyElement | string;
  /** Plain text rendered before the highlighted phrase. Defaults to `"Try clicking on "`. */
  leadingText?: string;
  /** Plain text rendered after the highlighted phrase. Defaults to `" to get started."`. */
  trailingText?: string;
  /** Theme color family for the rectangle stroke and pointer fill. Defaults to `"info"`. */
  color?: ThemeColor;
  /** Padding between the text and the rectangle outline, in `themeSpacing` units. Defaults to `1.5`. */
  padding?: number;
  /** Rectangle corner radius, in px (an SVG geometry attribute, not a CSS style value). Defaults to `8`. */
  cornerRadius?: number;
  /** Which corner of the rectangle the pointer glyph anchors near. Defaults to `"bottom-right"`. */
  pointerCorner?: PointerHighlightCorner;
  /** Plays once and never replays on later scroll-outs/scroll-ins when `true` (default). `false` re-plays (and reverses) every time visibility toggles. */
  once?: boolean;
  /** Milliseconds the rectangle's draw-in animation takes. Defaults to `600`. */
  duration?: number;
  /** Extra milliseconds the pointer glyph waits after the rectangle starts drawing. Defaults to `350`. */
  pointerStagger?: number;
  /** `IntersectionObserver` `rootMargin`. Defaults to `"-80px"` (fires slightly before fully visible). */
  viewMargin?: string;
  /** Extra class name merged onto the outer wrapper's native `class` attribute. */
  containerClassName?: string;
  /** Extra class name merged onto the rectangle overlay's native `class` attribute. */
  rectangleClassName?: string;
  /** Extra class name merged onto the pointer glyph's native `class` attribute. */
  pointerClassName?: string;
  /** Passthrough style merged onto the outer wrapper `<p>`. */
  style?: StyleObject;
}

// With `pathLength="100"` set on the rect, its length is normalized to
// exactly 100 units no matter how large the highlighted phrase renders —
// so the dash offset always animates over the same 0-100 range.
const RECT_PATH_LENGTH = 100;

const HIDDEN_RECT_FRAME: MotionKeyframe = { strokeDashoffset: RECT_PATH_LENGTH, opacity: 0 };
const VISIBLE_RECT_FRAME: MotionKeyframe = { strokeDashoffset: 0, opacity: 1 };

const POINTER_OFFSET_PX = 6;

function hiddenPointerFrame(corner: PointerHighlightCorner): MotionKeyframe {
  const fromLeft = corner === "top-left" || corner === "bottom-left";
  const fromTop = corner === "top-left" || corner === "top-right";
  return {
    opacity: 0,
    scale: 0.5,
    x: fromLeft ? -POINTER_OFFSET_PX : POINTER_OFFSET_PX,
    y: fromTop ? -POINTER_OFFSET_PX : POINTER_OFFSET_PX,
  };
}
const VISIBLE_POINTER_FRAME: MotionKeyframe = { opacity: 1, scale: 1, x: 0, y: 0 };

// Hand-authored solid-fill cursor/pointer-arrow glyph (24x24) — a simple
// geometric arrow silhouette, not traced from or sourced out of any icon
// library.
const POINTER_GLYPH_PATH = "M5 3 L5 19 L9.5 15 L12.5 21 L15.3 19.6 L12.3 13.6 L18 13.6 Z";

function cornerAnchorStyle(corner: PointerHighlightCorner): StyleObject {
  const style: StyleObject = { position: "absolute" };
  if (corner === "top-left" || corner === "bottom-left") style.insetInlineStart = themeSpacing(-3);
  else style.insetInlineEnd = themeSpacing(-3);
  if (corner === "top-left" || corner === "top-right") style.insetBlockStart = themeSpacing(-3);
  else style.insetBlockEnd = themeSpacing(-3);
  return style;
}

/**
 * Wraps an inline phrase so that, the first time it scrolls into view, an
 * animated rectangle outline draws itself around it and a small pointer
 * glyph pops in beside one corner — a one-shot "highlighter annotation"
 * triggered purely by scroll position, not by hover. Call with no arguments
 * for a working demo — a short sentence with a highlighted phrase.
 */
function pointerHighlight(props: PointerHighlightProps = {}): DomphyElement<"p"> {
  const highlighted = props.children ?? "this button";
  const leadingText = props.leadingText ?? "Try clicking on ";
  const trailingText = props.trailingText ?? " to get started.";
  const color = props.color ?? "info";
  const padding = props.padding ?? 1.5;
  const cornerRadius = props.cornerRadius ?? 8;
  const pointerCorner = props.pointerCorner ?? "bottom-right";
  const once = props.once ?? true;
  const duration = Math.max(120, props.duration ?? 600);
  const pointerStagger = Math.max(0, props.pointerStagger ?? 350);
  const viewMargin = props.viewMargin ?? "-80px";

  const rectangleFrame = toState<MotionKeyframe>(HIDDEN_RECT_FRAME);
  const pointerFrame = toState<MotionKeyframe>(hiddenPointerFrame(pointerCorner));

  const rectangleOverlay: DomphyElement<"svg"> = {
    svg: [
      {
        rect: null,
        x: "1",
        y: "1",
        width: "calc(100% - 2px)",
        height: "calc(100% - 2px)",
        rx: String(cornerRadius),
        fill: "none",
        pathLength: String(RECT_PATH_LENGTH),
        strokeDasharray: String(RECT_PATH_LENGTH),
        // Decorative outline path with no text of its own — exempt from the
        // missing-color contract, matching this package's other purely
        // decorative overlay elements (e.g. `heroHighlight.ts`'s marker bar).
        _doctorDisable: "missing-color",
        style: {
          stroke: (listener: Listener) => themeColor(listener, "shift-9", color),
        } as StyleObject,
        strokeWidth: "2",
        strokeLinecap: "round",
        $: [
          motion({
            initial: HIDDEN_RECT_FRAME,
            animate: rectangleFrame,
            transition: { duration, easing: "ease-out" },
          }),
        ],
      } as DomphyElement,
    ],
    ariaHidden: "true",
    class: props.rectangleClassName,
    style: {
      position: "absolute",
      inset: themeSpacing(-padding),
      overflow: "visible",
      pointerEvents: "none",
    } as StyleObject,
  } as DomphyElement<"svg">;

  const pointerElement: DomphyElement<"svg"> = {
    svg: [{ path: null, d: POINTER_GLYPH_PATH } as DomphyElement],
    viewBox: "0 0 24 24",
    fill: "currentColor",
    ariaHidden: "true",
    class: props.pointerClassName,
    style: {
      ...cornerAnchorStyle(pointerCorner),
      width: themeSpacing(5),
      height: themeSpacing(5),
      color: (listener: Listener) => themeColor(listener, "shift-9", color),
      pointerEvents: "none",
    } as StyleObject,
    $: [
      motion({
        initial: hiddenPointerFrame(pointerCorner),
        animate: pointerFrame,
        transition: { duration: Math.max(120, duration * 0.6), delay: pointerStagger, easing: "ease-out" },
      }),
    ],
  } as DomphyElement<"svg">;

  const highlightWrapper: DomphyElement<"span"> = {
    span: [
      { span: highlighted, style: { position: "relative", zIndex: 1 } } as DomphyElement,
      rectangleOverlay,
      pointerElement,
    ],
    class: props.containerClassName,
    style: { position: "relative", display: "inline-block" } as StyleObject,
    _onMount: (node: ElementNode) => {
      if (typeof IntersectionObserver !== "function") {
        // No IntersectionObserver support (e.g. non-browser test runtime) —
        // fail open and reveal immediately rather than never playing.
        rectangleFrame.set(VISIBLE_RECT_FRAME);
        pointerFrame.set(VISIBLE_POINTER_FRAME);
        return;
      }
      const element = node.domElement as Element;
      let hasPlayedOnce = false;
      const observer = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            if (entry.isIntersecting) {
              if (once && hasPlayedOnce) continue;
              hasPlayedOnce = true;
              rectangleFrame.set(VISIBLE_RECT_FRAME);
              pointerFrame.set(VISIBLE_POINTER_FRAME);
              if (once) {
                observer.disconnect();
              }
            } else if (!once) {
              rectangleFrame.set(HIDDEN_RECT_FRAME);
              pointerFrame.set(hiddenPointerFrame(pointerCorner));
            }
          }
        },
        { rootMargin: viewMargin },
      );
      observer.observe(element);
      node.addHook("Remove", () => observer.disconnect());
    },
  } as DomphyElement<"span">;

  return {
    p: [leadingText, highlightWrapper, trailingText],
    $: [paragraph()],
    style: props.style,
  } as DomphyElement<"p">;
}

export { pointerHighlight };

← Back to Aceternity UI catalog