Domphy

lens

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

Implementation notes

Full follow-cursor and static/controlled modes as specified, with the exact coordinate math from the spec's research note (zoom layer scaled about its own origin then translated by (lensRadius - xzoom, lensRadius - yzoom)), a circular clipped overlay with a themed ring/shadow, and a short eased CSS transition for the glide/fade. The magnified duplicate is produced via element.cloneNode(true) of the already-rendered base content (done once, imperatively, in _onMount) rather than mounting a second Domphy tree from the same children object, since a Domphy element is bound to one DOM node and can't be rendered twice in one tree — this works for arbitrary content (image or nested markup) per the spec's scope. Static/controlled mode is wired through Domphy's own reactive effect()/State (a State<{x,y}> passed as props.position re-positions the lens declaratively), while follow mode uses imperative mousemove writes, matching this package's established convention that high-frequency pointer tracking is imperative while externally-driven state is reactive.

Status: ported · Reference: Magic UI original

// magicui "Lens" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// magnifying-glass overlay: hovering (or, in static mode, an externally
// controlled coordinate) shows a small circular window with a zoomed-in copy
// of the same content, offset so the point under the cursor is centered.
//
// The magnified copy is produced with a plain `element.cloneNode(true)` of
// the already-rendered base content (done once, imperatively, in
// `_onMount`) rather than mounting a second Domphy tree from the same
// `children` element — a Domphy element object is bound to one DOM node, so
// the same object can't be rendered twice in one tree. Cloning the resulting
// DOM subtree works for arbitrary content (image, nested markup, …), matching
// the spec's "image or arbitrary element" scope, without double-binding.
//
// Coordinate math (content-space point (x, y), lens radius r, zoom z):
// the zoom layer is scaled by `z` about its own (0,0) origin, then translated
// by `(r - x*z, r - y*z)` so the scaled point lands exactly on the lens
// overlay's center — the "translate by -(cursor*zoom - radius)" idea from the
// spec's research note.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { effect, toState, type ValueOrState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

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

export interface LensProps {
  /** Content to magnify — image or arbitrary element. Defaults to a generic placeholder image. */
  children?: DomphyElement;
  /** Magnification multiplier. Defaults to 1.3. */
  zoomFactor?: number;
  /** Circular lens diameter, in `themeSpacing` units (≈170px at the default). Defaults to 42.5. */
  lensSizeUnits?: number;
  /** Pins the lens at `position` instead of following the pointer (useful for click-to-reveal, touch,
   * or fully programmatic control). Defaults to false. */
  isStatic?: boolean;
  /** Coordinate (px, relative to the content's own top-left) used when `isStatic` is true. Accepts a
   * `State` for reactive/programmatic control. Defaults to a point near the top-left of the content. */
  position?: ValueOrState<LensPosition>;
  /** Tint/border color for the lens ring. Defaults to `"primary"`. */
  lensColor?: ThemeColor;
  ariaLabel?: string;
  /** Seconds — smoothing speed for lens movement/opacity transitions. Defaults to 0.1. */
  duration?: number;
  style?: StyleObject;
}

const DEFAULT_ZOOM_FACTOR = 1.3;
const DEFAULT_LENS_SIZE_UNITS = 42.5; // themeSpacing(42.5) = 10.625em ≈ 170px at the base font size.
const DEFAULT_DURATION = 0.1;
const DEFAULT_STATIC_POSITION: LensPosition = { x: 110, y: 90 };

/** Default magnifiable content — a generic inline SVG placeholder photo (no network fetch). */
function defaultLensContent(): DomphyElement<"img"> {
  const markup =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220">' +
    '<rect width="320" height="220" fill="#d7dbe0"/>' +
    '<circle cx="250" cy="55" r="28" fill="#eef1f4"/>' +
    '<polygon points="0,220 110,110 170,170 230,90 320,220" fill="#aab1ba"/>' +
    "</svg>";
  return {
    img: null,
    src: `data:image/svg+xml,${encodeURIComponent(markup)}`,
    alt: "Placeholder photo",
    style: { display: "block", width: "100%", height: "auto", maxWidth: themeSpacing(90) },
  } as DomphyElement<"img">;
}

/**
 * A magnifying-glass hover overlay: shows a circular, zoomed-in copy of the
 * wrapped content centered on the cursor (or, in static mode, on an
 * externally controlled coordinate). Call with no arguments for a working
 * demo — a placeholder image with a 1.3x follow-cursor lens.
 */
function lens(props: LensProps = {}): DomphyElement<"div"> {
  const content = props.children ?? defaultLensContent();
  const zoomFactor = props.zoomFactor ?? DEFAULT_ZOOM_FACTOR;
  const lensSizeUnits = props.lensSizeUnits ?? DEFAULT_LENS_SIZE_UNITS;
  const isStatic = props.isStatic ?? false;
  const positionState = toState(props.position ?? DEFAULT_STATIC_POSITION);
  const lensColor = props.lensColor ?? "primary";
  const duration = props.duration ?? DEFAULT_DURATION;

  const baseContentElement: DomphyElement = {
    div: [content],
    dataLensContent: "true",
    style: { position: "relative", display: "block" },
  } as DomphyElement;

  // `_onMount` lives on this node (the deepest, last-rendered leaf in the
  // tree) rather than on the outer wrapper: a parent's `_onMount` fires
  // before its children are attached to the DOM (see ElementNode.render —
  // Mount fires, then children render), so querying for `[data-lens-content]`
  // from the wrapper's own `_onMount` would find nothing yet. By the time
  // THIS node mounts, everything earlier in render order — the wrapper, the
  // base content, and the overlay — is already live. Mirrors pointer.ts's
  // cursorElement placement.
  const zoomLayerElement: DomphyElement<"div"> = {
    div: [],
    dataLensZoomLayer: "true",
    ariaHidden: "true",
    style: { position: "absolute", insetBlockStart: 0, insetInlineStart: 0 },
    _onMount: (node: ElementNode) => {
      const zoomLayer = node.domElement as HTMLElement | null;
      const overlay = zoomLayer?.parentElement ?? null;
      const wrapper = overlay?.parentElement ?? null;
      if (!zoomLayer || !overlay || !wrapper || typeof window === "undefined") return;
      const baseContent = wrapper.querySelector('[data-lens-content="true"]') as HTMLElement | null;
      if (!baseContent) return;

      // Duplicate the already-rendered base content once for the magnified layer.
      const clone = baseContent.cloneNode(true) as HTMLElement;
      clone.removeAttribute("data-lens-content");
      zoomLayer.appendChild(clone);

      let lensRadius = overlay.offsetWidth / 2;

      const syncSizes = () => {
        lensRadius = overlay.offsetWidth / 2;
        const rect = baseContent.getBoundingClientRect();
        zoomLayer.style.width = `${rect.width}px`;
        zoomLayer.style.height = `${rect.height}px`;
      };
      syncSizes();

      const applyLensPosition = (x: number, y: number) => {
        overlay.style.transform = `translate(${x - lensRadius}px, ${y - lensRadius}px)`;
        zoomLayer.style.transformOrigin = "0 0";
        zoomLayer.style.transform =
          `translate(${lensRadius - x * zoomFactor}px, ${lensRadius - y * zoomFactor}px) ` +
          `scale(${zoomFactor})`;
      };

      let resizeObserver: ResizeObserver | null = null;
      if (typeof ResizeObserver !== "undefined") {
        resizeObserver = new ResizeObserver(() => syncSizes());
        resizeObserver.observe(baseContent);
      }

      let disposeEffect: (() => void) | null = null;
      let handleMove: ((event: MouseEvent) => void) | null = null;
      let handleEnter: ((event: MouseEvent) => void) | null = null;
      let handleLeave: (() => void) | null = null;

      if (isStatic) {
        // Reactive, declarative-style control: re-applies whenever the caller
        // updates a `State<LensPosition>` passed as `props.position`.
        disposeEffect = effect(() => {
          const position = positionState.get();
          applyLensPosition(position.x, position.y);
        });
      } else {
        // High-frequency pointer tracking is imperative (direct DOM writes),
        // matching the same tradeoff `pointer.ts`/`dock.ts` make for
        // continuous, purely visual cursor-following effects.
        handleMove = (event: MouseEvent) => {
          const rect = baseContent.getBoundingClientRect();
          applyLensPosition(event.clientX - rect.left, event.clientY - rect.top);
        };
        handleEnter = (event: MouseEvent) => {
          handleMove?.(event);
          overlay.style.opacity = "1";
        };
        handleLeave = () => {
          overlay.style.opacity = "0";
        };
        wrapper.addEventListener("mousemove", handleMove);
        wrapper.addEventListener("mouseenter", handleEnter);
        wrapper.addEventListener("mouseleave", handleLeave);
      }

      node.addHook("Remove", () => {
        disposeEffect?.();
        resizeObserver?.disconnect();
        if (handleMove) wrapper.removeEventListener("mousemove", handleMove);
        if (handleEnter) wrapper.removeEventListener("mouseenter", handleEnter);
        if (handleLeave) wrapper.removeEventListener("mouseleave", handleLeave);
      });
    },
  };

  // `_doctorDisable` is a doctor-only annotation not present in core's strict
  // `PartialElement` type — build through an untyped literal, then assert, so
  // the excess-property check doesn't fire (mirrors dock.ts's separator()).
  // This overlay is a decorative, aria-hidden magnified duplicate with no
  // text of its own, so it is exempt from the missing-color contract.
  const overlayElement = {
    div: [zoomLayerElement],
    dataLensOverlay: "true",
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      width: themeSpacing(lensSizeUnits),
      height: themeSpacing(lensSizeUnits),
      borderRadius: "50%",
      overflow: "hidden",
      pointerEvents: "none",
      zIndex: 10,
      opacity: isStatic ? 1 : 0,
      transform: "translate(-9999px, -9999px)",
      outline: (listener: Listener) => `${themeSpacing(1)} solid ${themeColor(listener, "shift-9", lensColor)}`,
      outlineOffset: "-1px",
      boxShadow: (listener: Listener) =>
        `0 ${themeSpacing(2)} ${themeSpacing(8)} ${themeColor(listener, "shift-4")}`,
      transition: `transform ${duration}s ease-out, opacity ${Math.max(duration * 1.5, 0.05)}s ease-out`,
      willChange: "transform, opacity",
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [baseContentElement, overlayElement],
    ariaLabel: props.ariaLabel,
    style: {
      position: "relative",
      display: "inline-block",
      ...(props.style ?? {}),
    },
  };
}

export { lens };

← Back to Magic UI catalog