Domphy

pointer

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

Implementation notes

Hover zone hides the native cursor (CSS cursor) and tracks the pointer with a custom visual. Position tracking is imperative (direct DOM mutation on mousemove/rAF), matching the spec's own guidance that a lerp-per-rAF is an acceptable substitute for Framer Motion's spring. Enter/leave fade+scale via CSS transition; the default glyph runs an independent CSS @keyframes scale/rotate loop layered on top of position tracking, demonstrating the spec's 'two independent animation concerns'. Default cursor visual is a two-tone ring (primary outline + surface-colored center) rather than any of the gallery's specific glyphs (heart/emoji/dot) — the spec explicitly states there is no single canonical default and treats the visual as a pure children slot, so any reasonable default is correct. One implementation subtlety worth flagging for reviewers: the _onMount hook is attached to the cursor element itself (not the outer container), because in Domphy's render order a parent's _onMount fires before its children are attached to the DOM — attaching to the container and querying for the cursor child inside its own _onMount would find nothing yet.

Status: ported · Reference: Magic UI original

// magicui "Pointer" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A hover
// zone that hides the native OS cursor and replaces it with a small,
// freely-swappable visual (a shape, emoji, or icon) that tracks the mouse in
// real time while it's inside the zone, fading/scaling in on enter and out
// on leave. Position tracking is done imperatively (direct DOM writes on
// every mousemove/rAF tick) rather than through reactive state, since it is
// a high-frequency, purely visual concern — the same tradeoff other
// lifecycle-hook-driven patches in this file make for canvas refs and
// third-party integrations.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { paragraph, strong } from "@domphy/ui";
import { themeColor, themeSpacing } from "@domphy/theme";

export interface PointerOffset {
  x: number;
  y: number;
}

export interface PointerProps {
  /** Custom cursor visual — freely swappable SVG/emoji/element. Defaults to a small pulsing two-tone ring. */
  children?: DomphyElement;
  /** The page content the hover zone wraps. Defaults to a short instructional demo panel. */
  content?: DomphyElement[];
  /** Offset (raw pixels) between the real pointer tip and the custom cursor's anchor point. Defaults to `{ x: 16, y: 16 }`. */
  offset?: PointerOffset;
  /** Smooths motion with a per-frame lerp instead of snapping directly to the pointer. Defaults to `true`. */
  smooth?: boolean;
  /** Lerp factor (0–1) used when `smooth` is true — higher tracks faster/snappier. Defaults to `0.25`. */
  smoothing?: number;
  style?: StyleObject;
}

const DEFAULT_OFFSET: PointerOffset = { x: 16, y: 16 };

const GLYPH_LOOP_KEYFRAMES = {
  "0%,100%": { transform: "scale(1) rotate(0deg)" },
  "50%": { transform: "scale(1.15) rotate(6deg)" },
};
const GLYPH_LOOP_ANIMATION_NAME = `pointer-glyph-loop-${hashString(JSON.stringify(GLYPH_LOOP_KEYFRAMES))}`;

function clamp(value: number, min: number, max: number): number {
  return Math.min(max, Math.max(min, value));
}

/** Default cursor visual: a two-tone ring (primary outline, surface-colored center) with a
 * continuous, independent scale/rotate loop — demonstrates that intrinsic decoration can run
 * on top of position tracking regardless of what the caller supplies via `children`. */
function defaultCursorGlyph(): DomphyElement<"span"> {
  return {
    span: null,
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      display: "block",
      boxSizing: "border-box",
      width: themeSpacing(6),
      height: themeSpacing(6),
      borderRadius: "50%",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
      border: (listener: Listener) => `${themeSpacing(1.5)} solid ${themeColor(listener, "shift-9", "primary")}`,
      animation: `${GLYPH_LOOP_ANIMATION_NAME} 1.1s ease-in-out infinite`,
      [`@keyframes ${GLYPH_LOOP_ANIMATION_NAME}`]: GLYPH_LOOP_KEYFRAMES,
    },
  } as DomphyElement<"span">;
}

function defaultContent(): DomphyElement[] {
  return [
    {
      div: [
        { strong: "Hover to preview", $: [strong()] },
        {
          p: "This zone hides the native cursor and tracks a custom visual while your pointer is inside it.",
          $: [paragraph()],
        },
      ],
      style: {
        display: "flex",
        flexDirection: "column",
        gap: themeSpacing(2),
        alignItems: "center",
        justifyContent: "center",
        textAlign: "center",
        height: "100%",
        paddingBlock: themeSpacing(12),
        paddingInline: themeSpacing(6),
      },
    },
  ];
}

/**
 * Hover zone that swaps the native OS cursor for a custom tracking visual.
 * Call with no arguments for a working demo — an instructional panel with a
 * pulsing two-tone ring cursor that follows the mouse while hovering.
 */
function pointer(props: PointerProps = {}): DomphyElement<"div"> {
  const offset = props.offset ?? DEFAULT_OFFSET;
  const smooth = props.smooth ?? true;
  const smoothing = clamp(props.smoothing ?? 0.25, 0.01, 1);
  const glyph = props.children ?? defaultCursorGlyph();
  const content = props.content ?? defaultContent();

  const cursorElement: DomphyElement<"div"> = {
    div: [glyph],
    dataPointerCursor: "true",
    ariaHidden: "true",
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      pointerEvents: "none",
      zIndex: 50,
      opacity: 0,
      transform: "translate(-9999px, -9999px) scale(0.5)",
      transition: "opacity 150ms ease-out, transform 150ms ease-out",
      willChange: "transform, opacity",
    },
    // Mounted on the cursor element itself (not the outer container): a
    // parent's `_onMount` fires before its children are attached to the DOM
    // (see ElementNode.render — Mount fires, then children render), so
    // querying for this element from the container's own `_onMount` would
    // find nothing yet. By the time THIS node mounts, its `parentElement` is
    // already the live, attached container.
    _onMount: (node: ElementNode) => {
      const cursor = node.domElement as HTMLElement;
      const container = cursor.parentElement;
      if (!container) return;

      let targetX = 0;
      let targetY = 0;
      let currentX = 0;
      let currentY = 0;
      let active = false;
      let frameHandle: number | null = null;

      const applyTransform = (x: number, y: number, scale: number) => {
        cursor.style.transform = `translate(${x + offset.x}px, ${y + offset.y}px) scale(${scale})`;
      };

      const tick = () => {
        // Belt-and-suspenders stop condition: some hosts (e.g. a test harness
        // that wipes the DOM directly instead of going through the
        // framework's removal lifecycle) never fire the "Remove" hook below.
        // Once the cursor element is detached, the native mouseenter/
        // mouseleave events that would otherwise flip `active` back to
        // `false` can never fire again either, so bailing here is what
        // actually stops the loop from leaking forever.
        if (!cursor.isConnected) {
          frameHandle = null;
          return;
        }
        currentX += (targetX - currentX) * smoothing;
        currentY += (targetY - currentY) * smoothing;
        applyTransform(currentX, currentY, 1);
        if (active) frameHandle = requestAnimationFrame(tick);
        else frameHandle = null;
      };

      const positionFromEvent = (event: MouseEvent) => {
        const rect = container.getBoundingClientRect();
        return { x: event.clientX - rect.left, y: event.clientY - rect.top };
      };

      const handleMove = (event: MouseEvent) => {
        const point = positionFromEvent(event);
        targetX = point.x;
        targetY = point.y;
        if (!smooth) {
          currentX = targetX;
          currentY = targetY;
          applyTransform(currentX, currentY, 1);
        }
      };

      const handleEnter = (event: MouseEvent) => {
        const point = positionFromEvent(event);
        currentX = targetX = point.x;
        currentY = targetY = point.y;
        applyTransform(currentX, currentY, 1);
        cursor.style.opacity = "1";
        active = true;
        if (smooth && frameHandle === null) frameHandle = requestAnimationFrame(tick);
      };

      const handleLeave = () => {
        cursor.style.opacity = "0";
        active = false;
        if (frameHandle !== null) {
          cancelAnimationFrame(frameHandle);
          frameHandle = null;
        }
      };

      container.addEventListener("mousemove", handleMove);
      container.addEventListener("mouseenter", handleEnter);
      container.addEventListener("mouseleave", handleLeave);

      node.addHook("Remove", () => {
        container.removeEventListener("mousemove", handleMove);
        container.removeEventListener("mouseenter", handleEnter);
        container.removeEventListener("mouseleave", handleLeave);
        if (frameHandle !== null) cancelAnimationFrame(frameHandle);
      });
    },
  };

  return {
    div: [...content, cursorElement],
    style: {
      position: "relative",
      overflow: "hidden",
      cursor: "none",
      ...(props.style ?? {}),
    },
  };
}

export { pointer };

← Back to Magic UI catalog