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

Props

PropTypeDescription
childrenDomphyElementContent to magnify — image or arbitrary element. Defaults to a generic placeholder image.
zoomFactornumberMagnification multiplier (must be ≥ 1). Defaults to 1.3.
lensSizeUnitsnumberCircular lens diameter, in themeSpacing units (≈170px at the default). Defaults to 42.5.
isStaticbooleanPins the lens at position instead of following the pointer (useful for click-to-reveal, touch, or fully programmatic control). Defaults to false.
positionValueOrState<LensPosition>Coordinate (px, relative to the content's own top-left) used when isStatic is true. Accepts a State for reactive/programmatic control. Defaults to { x: 0, y: 0 }.
defaultPositionLensPositionWhen provided (and not static), keeps the lens always visible: resting at this coordinate while not hovering and following the cursor while hovering.
lensColorstringCSS color used only as the radial-gradient mask fill. The mask reveals via its alpha, so the color itself is visually inert — any opaque value works. Defaults to "black".
ariaLabelstring
durationnumberSeconds — the mount scale/opacity transition speed. Does not slow cursor tracking, which is always instant. Defaults to 0.1.
styleStyleObject
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/default-position mode, an
// externally controlled or resting coordinate) shows a circular window with a
// zoomed-in copy of the same content, revealed solely through a radial-gradient
// mask (no border, outline, or shadow — the lens has no visible chrome of its
// own) and 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.
//
// Element split, so position tracking and the mount scale/fade animation
// never fight over one `transform` (position must be instant on every
// mousemove; scale/opacity must ease over `duration`):
//   overlay      — circular window; carries the position `translate(...)`
//                  (instant, untransitioned) plus `opacity` (eased).
//   scaleWrapper — clips the zoom layer, carries the radial-gradient mask and
//                  the mount `scale(...)` (eased). Its box is centered on the
//                  cursor, so scaling about its own center matches upstream's
//                  "scale about the cursor point" origin.
//   zoomLayer    — the cloned content, `scale(zoomFactor)` translated by
//                  `(r - x*z, r - y*z)` so the scaled content point (x, y)
//                  lands on the window's center.

import type {
  DomphyElement,
  ElementNode,
  State,
  StyleObject,
} from "@domphy/core";
import { behavior, effect, toState, type ValueOrState } from "@domphy/core";
import { 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 (must be ≥ 1). 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 `{ x: 0, y: 0 }`. */
  position?: ValueOrState<LensPosition>;
  /** When provided (and not static), keeps the lens always visible: resting at this coordinate while
   * not hovering and following the cursor while hovering. */
  defaultPosition?: LensPosition;
  /** CSS color used only as the radial-gradient mask fill. The mask reveals via its alpha, so the
   * color itself is visually inert — any opaque value works. Defaults to `"black"`. */
  lensColor?: string;
  ariaLabel?: string;
  /** Seconds — the mount scale/opacity transition speed. Does not slow cursor tracking, which is
   * always instant. 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_POSITION: LensPosition = { x: 0, y: 0 };
const DEFAULT_LENS_COLOR = "black";
const DEFAULT_ARIA_LABEL = "Zoom Area";
const ENTER_SCALE = "scale(1)";
const INITIAL_SCALE = "scale(0.58)";
const EXIT_SCALE = "scale(0.8)";

/** Default magnifiable content — a generic inline SVG placeholder photo (no network fetch).
 * Deliberately an abstract gradient-and-bokeh composition rather than the familiar
 * "rectangle + sun circle + mountain silhouette" glyph most browsers/icon sets use for
 * a *missing* image — that shape would read as a broken image instead of a placeholder one. */
function defaultLensContent(): DomphyElement<"img"> {
  const markup =
    '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220">' +
    '<defs><linearGradient id="lensGradient" x1="0" y1="0" x2="1" y2="1">' +
    '<stop offset="0%" stop-color="#38bdf8"/>' +
    '<stop offset="55%" stop-color="#818cf8"/>' +
    '<stop offset="100%" stop-color="#f472b6"/>' +
    "</linearGradient></defs>" +
    '<rect width="320" height="220" fill="url(#lensGradient)"/>' +
    '<circle cx="90" cy="60" r="70" fill="#ffffff" opacity="0.16"/>' +
    '<circle cx="240" cy="150" r="95" fill="#ffffff" opacity="0.12"/>' +
    "</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 / default-position
 * mode, on an externally controlled or resting 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_POSITION);
  const defaultPosition = props.defaultPosition;
  const lensColor = props.lensColor ?? DEFAULT_LENS_COLOR;
  const duration = props.duration ?? DEFAULT_DURATION;

  if (zoomFactor < 1) throw new Error("zoomFactor must be greater than 1");
  if (lensSizeUnits < 0) throw new Error("lensSize must be greater than 0");

  // isStatic pins it; a defaultPosition keeps it parked-but-visible. Either way
  // the lens starts shown (no hover-in animation gating its first paint).
  const alwaysVisible = isStatic || defaultPosition !== undefined;

  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 },
    ...behavior<{
      zoomFactor: number;
      isStatic: boolean;
      positionState: State<LensPosition>;
      defaultPosition?: LensPosition;
    }>("magicui-lens", attachLens, {
      zoomFactor,
      isStatic,
      positionState,
      defaultPosition,
    }),
  };

  // Clips the zoom layer and carries the radial-gradient mask (the sole reveal
  // mechanism — no border/outline/shadow) plus the eased mount scale. `lensColor`
  // is only the mask fill; the mask reveals via alpha, so its color is inert.
  const maskImage = `radial-gradient(circle closest-side at 50% 50%, ${lensColor} 100%, transparent 100%)`;
  const scaleWrapperElement: DomphyElement<"div"> = {
    div: [zoomLayerElement],
    ariaHidden: "true",
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      width: "100%",
      height: "100%",
      overflow: "hidden",
      maskImage,
      WebkitMaskImage: maskImage,
      transform: alwaysVisible ? ENTER_SCALE : INITIAL_SCALE,
      transformOrigin: "center",
      transition: `transform ${duration}s ease-out`,
      willChange: "transform",
    } as StyleObject,
  };

  // `_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: [scaleWrapperElement],
    dataLensOverlay: "true",
    ariaHidden: "true",
    _doctorDisable: "missing-color",
    style: {
      position: "absolute",
      insetBlockStart: 0,
      insetInlineStart: 0,
      width: themeSpacing(lensSizeUnits),
      height: themeSpacing(lensSizeUnits),
      pointerEvents: "none",
      zIndex: 50,
      opacity: alwaysVisible ? 1 : 0,
      // Position is written here on every move (untransitioned). Only opacity
      // eases, so tracking never lags.
      transform: "translate(-9999px, -9999px)",
      transition: `opacity ${duration}s ease-out`,
      willChange: "opacity, transform",
    } as StyleObject,
  } as DomphyElement<"div">;

  return {
    div: [baseContentElement, overlayElement],
    role: "region",
    ariaLabel: props.ariaLabel ?? DEFAULT_ARIA_LABEL,
    tabindex: 0,
    onKeyDown: (event: KeyboardEvent) => {
      if (event.key !== "Escape") return;
      (event.currentTarget as HTMLElement | null)?.dispatchEvent(
        new MouseEvent("mouseleave"),
      );
    },
    style: {
      position: "relative",
      zIndex: 20,
      overflow: "hidden",
      borderRadius: themeSpacing(3),
      ...(props.style ?? {}),
    },
  };
}

function attachLens(
  node: ElementNode,
  initialProps: {
    zoomFactor: number;
    isStatic: boolean;
    positionState: State<LensPosition>;
    defaultPosition?: LensPosition;
  },
) {
  let props = initialProps;
  const zoomLayer = node.domElement as HTMLElement | null;
  const scaleWrapper = zoomLayer?.parentElement ?? null;
  const overlay = scaleWrapper?.parentElement ?? null;
  const wrapper = overlay?.parentElement ?? null;
  if (
    !zoomLayer ||
    !scaleWrapper ||
    !overlay ||
    !wrapper ||
    typeof window === "undefined"
  ) {
    return { update() {}, destroy() {} };
  }
  const baseContent = wrapper.querySelector(
    '[data-lens-content="true"]',
  ) as HTMLElement | null;
  if (!baseContent) return { update() {}, destroy() {} };

  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 * props.zoomFactor}px, ${lensRadius - y * props.zoomFactor}px) ` +
      `scale(${props.zoomFactor})`;
  };

  const showLens = () => {
    overlay.style.opacity = "1";
    scaleWrapper.style.transform = ENTER_SCALE;
  };
  const hideLens = () => {
    overlay.style.opacity = "0";
    scaleWrapper.style.transform = EXIT_SCALE;
  };

  const positionFromEvent = (event: MouseEvent) => {
    const rect = baseContent.getBoundingClientRect();
    applyLensPosition(event.clientX - rect.left, event.clientY - rect.top);
  };

  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 (props.isStatic) {
    disposeEffect = effect(() => {
      const position = props.positionState.get();
      applyLensPosition(position.x, position.y);
    });
  } else if (props.defaultPosition) {
    const parked = props.defaultPosition;
    applyLensPosition(parked.x, parked.y);
    handleMove = positionFromEvent;
    handleLeave = () => applyLensPosition(parked.x, parked.y);
    wrapper.addEventListener("mousemove", handleMove);
    wrapper.addEventListener("mouseleave", handleLeave);
  } else {
    handleMove = positionFromEvent;
    handleEnter = (event: MouseEvent) => {
      positionFromEvent(event);
      showLens();
    };
    handleLeave = hideLens;
    wrapper.addEventListener("mousemove", handleMove);
    wrapper.addEventListener("mouseenter", handleEnter);
    wrapper.addEventListener("mouseleave", handleLeave);
  }

  return {
    update(next: typeof initialProps) {
      if (next.positionState !== props.positionState && disposeEffect) {
        disposeEffect();
        disposeEffect = effect(() => {
          const position = next.positionState.get();
          applyLensPosition(position.x, position.y);
        });
      }
      props = next;
    },
    destroy() {
      disposeEffect?.();
      resizeObserver?.disconnect();
      if (handleMove) wrapper.removeEventListener("mousemove", handleMove);
      if (handleEnter) wrapper.removeEventListener("mouseenter", handleEnter);
      if (handleLeave) wrapper.removeEventListener("mouseleave", handleLeave);
    },
  };
}

export { lens };

← Back to Magic UI catalog