Skip to content

Components / Pointer

Lens

A round magnifier that follows the pointer over a picture. The slot is drawn twice, once as it is and once scaled up inside the lens, so it magnifies an image, an SVG drawing or any markup alike. The lens is placed by two custom properties a few lines of script write on pointer moves; the keyboard focuses the picture, which shows the lens in its middle, moves it with the arrow keys and puts it away with Escape. Without JavaScript the picture is shown whole.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

840 B raw

457 B gzip.

Uses the shared runtime (1.9 kB raw, once per site). With those included: 2.5 kB raw.

CSS 5.3 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 105 · Firefox 110 · Safari 16; needs container-units. Elsewhere: without container units the lens shows the picture at the lens's own width.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import Lens from '@moonarc/core/Lens';
---
<Lens zoom={2.5} size="10rem" label="The kitchen, magnified under the pointer">
  <img src="/kitchen.webp" alt="An open kitchen with a long oak counter" width="1600" height="1067" />
</Lens>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the shared runtime and the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/lens.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy runtime.ts.

Lens.astro
---
/**
 * Lens — a round magnifier that follows the pointer over a picture: the slot is drawn twice, once as it is and once
 * scaled up inside the lens, so it magnifies an <img>, an <Image>, an SVG drawing or any markup alike. The lens is placed
 * by two custom properties a few lines of script write on pointer moves; a keyboard focuses the picture, which shows the
 * lens in the middle, and moves it with the arrow keys; Escape puts it away. Without JavaScript the picture is shown
 * whole and nothing magnifies.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** How many times larger the lens shows the picture. */
  zoom?: number;
  /** The lens's width, any CSS length. */
  size?: string;
  /** What the picture shows, read before it (the copy in the lens is hidden from a screen reader). */
  label?: string;
}

const { zoom = 2.5, size = '10rem', label, class: className, style, ...rest } = Astro.props;
const html = await Astro.slots.render('default');
const vars = [`--ma-lens-zoom:${zoom}`, `--ma-lens-size:${size}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-lens', className]} data-ma-lens tabindex="0" role="group" aria-label={label} aria-roledescription="magnifier" style={vars} {...rest}>
  <div class="ma-lens__base" set:html={html} />
  <div class="ma-lens__glass" aria-hidden="true"><div class="ma-lens__copy" inert set:html={html} /></div>
</div>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-lens]', (el, { signal }) => {
    const at = (x: number, y: number) => {
      el.style.setProperty('--ma-lens-x', String(Math.min(1, Math.max(0, x))));
      el.style.setProperty('--ma-lens-y', String(Math.min(1, Math.max(0, y))));
    };
    let x = 0.5;
    let y = 0.5;
    const on = (t: string, f: (e: Event) => void) => el.addEventListener(t, f, { signal });
    on('pointermove', (e) => {
      const p = e as PointerEvent;
      const r = el.getBoundingClientRect();
      x = (p.clientX - r.left) / r.width;
      y = (p.clientY - r.top) / r.height;
      at(x, y);
      el.dataset.on = '';
    });
    on('pointerleave', () => document.activeElement !== el && delete el.dataset.on);
    on('focus', () => { at(x, y); el.dataset.on = ''; });
    on('blur', () => delete el.dataset.on);
    on('keydown', (e) => {
      const k = (e as KeyboardEvent).key;
      if (k === 'Escape') return delete el.dataset.on;
      const d = { ArrowLeft: [-0.05, 0], ArrowRight: [0.05, 0], ArrowUp: [0, -0.05], ArrowDown: [0, 0.05] }[k];
      if (!d) return;
      e.preventDefault();
      x = Math.min(1, Math.max(0, x + d[0]!));
      y = Math.min(1, Math.max(0, y + d[1]!));
      at(x, y);
      el.dataset.on = '';
    });
  });
</script>

<style is:global>
  @layer components {
    :where(.ma-lens) {
      --ma-lens-x: 0.5;
      --ma-lens-y: 0.5;
      position: relative;
      display: block;
      container-type: inline-size;
      overflow: hidden;
      border-radius: var(--ma-radius-lg, 1rem);
      cursor: zoom-in;
      touch-action: pan-y;
    }
    :where(.ma-lens:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-lens__base > *), :where(.ma-lens__copy > *) { display: block; inline-size: 100%; block-size: auto; }
    /* the lens sits on the point; inside it the copy is as large as the picture, scaled up about its own corner and
       moved so the point lands in the lens's middle (a translate's percentages are of the copy's own box) */
    :where(.ma-lens__glass) {
      position: absolute;
      /* physical sides: the point is where the pointer is, whatever the writing direction */
      left: calc(var(--ma-lens-x) * 100% - var(--ma-lens-size) / 2);
      top: calc(var(--ma-lens-y) * 100% - var(--ma-lens-size) / 2);
      inline-size: var(--ma-lens-size);
      aspect-ratio: 1;
      overflow: hidden;
      border-radius: 50%;
      box-shadow: 0 0 0 3px var(--background, #fff), 0 12px 32px -10px oklch(0 0 0 / 0.5);
      pointer-events: none;
      opacity: 0;
      scale: 0.85;
      transition: opacity var(--ma-duration-fast, 150ms) var(--ma-ease-out, ease-out), scale var(--ma-duration-fast, 150ms) var(--ma-ease-out, ease-out);
    }
    :where(.ma-lens[data-on] .ma-lens__glass) { opacity: 1; scale: 1; }
    :where(.ma-lens__copy) {
      position: absolute;
      top: 0;
      left: 0;
      inline-size: 100cqi;
      transform-origin: 0 0;
      translate: calc(var(--ma-lens-size) / 2 - var(--ma-lens-x) * var(--ma-lens-zoom) * 100%) calc(var(--ma-lens-size) / 2 - var(--ma-lens-y) * var(--ma-lens-zoom) * 100%);
      scale: var(--ma-lens-zoom);
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-lens__glass) { transition: none; scale: 1; }
    }
  }
</style>

Props

PropTypeDefaultDescription
zoomnumber2.5How many times larger the lens shows the picture.
sizestring'10rem'The lens's width, any CSS length.
labelstringnoneWhat the picture is, read before it. The copy inside the lens is hidden from a screen reader and inert.

Reduced motion

The lens appears without its fade and scale.

With ClientRouter

Bound per element through the runtime; a fresh page binds again.

Why it is built this way

Replaces: Lens (Magic UI) · Lens (Aceternity) · react-image-magnify. See the migration table.

Home Art

Backgrounds

A building drawn flat, for a listing that has no photograph yet: a house, a terrace, an apartment block, a villa, a cabin, a tower, an office or a shop, in the colour of the block or tone it sits on, with a shade, a light and the windows mixed from it.

every browserpointer
The scene as a line drawingThe scene in colour

drag · click · ← →

Compares a before and an after image with a line you drag between them.

newly · Chrome 124 · Firefox 121 · Safari 16.5pointerclick
Study 1Study 2Study 3

click · grows from its thumbnail · ← →

A thumbnail gallery whose images open full size: the image grows out of its own thumbnail on a same-document view transition and returns to it on close, and ← → move through the set.

newly · Chrome 111 · Firefox 144 · Safari 18click

Tilt

Pointer

A card leans toward the cursor in 3D, lifts slightly, catches a glare, and settles back on the active preset's spring when the cursor leaves.

every browserpointerhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown