# Lens (Moonarc)

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.

- Import: `import Lens from '@moonarc/core/Lens'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/lens.json`
- Tier B · category pointer · trigger pointer, hover
- Readout: `<Lens zoom={2.5} size="10rem"><img … /></Lens>`
- Browser support: widely (Chrome 105 · Firefox 110 · Safari 16); elsewhere: without container units the lens shows the picture at the lens's own width
- Measured cost: 840 B raw JS · 457 B gzip · + runtime (with dependencies 2.5 kB raw; CSS 5.3 kB raw)
- Page: https://moonarc.dev/components/lens/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `zoom` | `number` | `2.5` | How many times larger the lens shows the picture. |
| `size` | `string` | `'10rem'` | The lens's width, any CSS length. |
| `label` | `string` | none | What the picture is, read before it. The copy inside the lens is hidden from a screen reader and inert. |

## Usage

```astro
<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>
```

## Reduced motion

The lens appears without its fade and scale.

## With ClientRouter

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

## Craft

- The copy inside the lens is as wide as the picture (100cqi of the lens's container), scaled about its corner and moved by a translate whose percentages are of its own box, so the point under the pointer lands in the lens's middle at any zoom.
- The script writes two numbers, the pointer's share across and down; every length is CSS.
- Physical sides, left and top, place the lens: the pointer is where it is whatever the writing direction.
- The second copy is inert and hidden from assistive technology, so a link or a button in the picture is reached once.

## Replaces

- Lens (Magic UI)
- Lens (Aceternity)
- react-image-magnify

## Source

```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>

```
