beta
Lens — <mv-lens>
Magnifier that follows the cursor over an image or any content: an enlarged copy clipped to a circle (or square) anchored at the pointer, glass rim, animated opening, optional fixed position and keyboard movement.
| Category | Media |
|---|---|
| Type | Web Component (<mv-lens>) |
| Status | beta |
| Keywords | lens, magnifier, zoom, loupe, image, product, hover, magnifying-glass |
When to use
- Visitors need to inspect fine detail of a product photo, artwork or map without leaving the page
- A fixed magnifier should call out a specific area of an image or diagram
- Any HTML content, not only images, should be magnified under the pointer or with the keyboard
Avoid when
- A full product page gallery with thumbnails, hover zoom and fullscreen is needed → use Product Gallery instead
- The magnified content is small text meant to be read; render it at a readable size instead
Install
node scripts/add.mjs lens --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["lens"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/pointer.js, components/lens/lens.js, components/lens/lens.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(18rem,1fr));gap:2rem;align-items:center;width:100%;max-width:58rem">
<mv-lens zoom="3" size="190" label="Lumen watch dial" style="max-width:26rem;width:100%;justify-self:center;border-radius:var(--mv-radius-2xl)">
<svg viewBox="0 0 600 600" role="img" aria-label="Lumen Automatic watch, midnight-blue sunburst dial, date at 3 o’clock">
<defs>
<radialGradient id="lens-demo-bg" cx="50%" cy="40%" r="75%"><stop offset="0" stop-color="#e9e7f5"/><stop offset="1" stop-color="#b9b5cf"/></radialGradient>
<radialGradient id="lens-demo-dial" cx="45%" cy="38%" r="70%"><stop offset="0" stop-color="#3b3f9c"/><stop offset=".55" stop-color="#1c1f5e"/><stop offset="1" stop-color="#0b0c2a"/></radialGradient>
<linearGradient id="lens-demo-case" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f8f8fb"/><stop offset=".45" stop-color="#9a98a8"/><stop offset=".55" stop-color="#d9d8e0"/><stop offset="1" stop-color="#6f6d7d"/></linearGradient>
<linearGradient id="lens-demo-hand" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#c9c8d6"/></linearGradient>
<path id="lens-demo-arc" d="M150 300a150 150 0 0 1 300 0"/>
</defs>
<rect width="600" height="600" fill="url(#lens-demo-bg)"/>
<rect x="262" y="0" width="76" height="70" rx="6" fill="#2a2b48"/><rect x="262" y="530" width="76" height="70" rx="6" fill="#2a2b48"/>
<rect x="548" y="282" width="22" height="36" rx="5" fill="url(#lens-demo-case)"/>
<circle cx="300" cy="300" r="262" fill="url(#lens-demo-case)"/>
<circle cx="300" cy="300" r="244" fill="#15163f"/>
<circle cx="300" cy="300" r="236" fill="url(#lens-demo-dial)"/>
<g opacity=".08" stroke="#fff" stroke-width="1"><path d="M300 64V536M64 300H536M133 133 467 467M467 133 133 467M209 81 391 519M391 81 209 519M81 209 519 391M81 391 519 209"/></g>
<circle cx="300" cy="300" r="222" fill="none" stroke="#c7c9ee" stroke-width="10" stroke-dasharray="1.2 22.03" stroke-dashoffset=".6" transform="rotate(-90 300 300)"/>
<circle cx="300" cy="300" r="214" fill="none" stroke="#fff" stroke-width="26" stroke-dasharray="6 106.05" stroke-dashoffset="3" transform="rotate(-90 300 300)"/>
<g fill="#dfe0ff" font-family="Georgia, serif" font-size="10" text-anchor="middle">
<text x="300" y="104">60</text><text x="498" y="304">15</text><text x="300" y="506">30</text><text x="102" y="304">45</text>
</g>
<text x="300" y="196" fill="#fff" font-family="Georgia, serif" font-size="30" letter-spacing="8" text-anchor="middle">LUMEN</text>
<text x="300" y="216" fill="#aeb1ea" font-family="Arial, sans-serif" font-size="8.5" letter-spacing="3" text-anchor="middle">AUTOMATIC · 28,800 VPH</text>
<text fill="#8f93d8" font-family="Arial, sans-serif" font-size="6" letter-spacing="2.2" text-anchor="middle"><textPath href="#lens-demo-arc" startOffset="50%">HAND-ASSEMBLED · 72-HOUR POWER RESERVE · NO. 0417/1200</textPath></text>
<text x="300" y="418" fill="#c7c9ee" font-family="Arial, sans-serif" font-size="9" letter-spacing="2" text-anchor="middle">CERTIFIED CHRONOMETER</text>
<text x="300" y="432" fill="#8f93d8" font-family="Arial, sans-serif" font-size="6" letter-spacing="1.6" text-anchor="middle">SAPPHIRE · 100 M · 25 JEWELS</text>
<text x="300" y="512" fill="#7f83c8" font-family="Arial, sans-serif" font-size="5.5" letter-spacing="1.8" text-anchor="middle">FRANCE</text>
<rect x="420" y="287" width="38" height="26" rx="3" fill="#f7f7fb" stroke="#9a98a8" stroke-width="2"/>
<text x="439" y="306" fill="#1c1f5e" font-family="Arial, sans-serif" font-size="17" font-weight="700" text-anchor="middle">23</text>
<g transform="rotate(-62 300 300)"><path d="M293 300 297 170 300 158 303 170 307 300Z" fill="url(#lens-demo-hand)"/><path d="M298 250 300 175 302 250Z" fill="#8ee6c5"/></g>
<g transform="rotate(58 300 300)"><path d="M295 300 298 108 300 96 302 108 305 300Z" fill="url(#lens-demo-hand)"/><path d="M299 250 300 115 301 250Z" fill="#8ee6c5"/></g>
<g transform="rotate(196 300 300)"><path d="M299 340 299.3 90 300.7 90 301 340Z" fill="#ff6b4a"/><circle cx="300" cy="120" r="5" fill="none" stroke="#ff6b4a" stroke-width="2"/></g>
<circle cx="300" cy="300" r="9" fill="#e8e8f2"/><circle cx="300" cy="300" r="3.5" fill="#ff6b4a"/>
</svg>
</mv-lens>
<div style="display:grid;gap:1rem;max-width:24rem">
<p style="margin:0;font:600 .75rem/1 var(--mv-font-mono);letter-spacing:.12em;text-transform:uppercase;color:var(--mv-accent-fg)">Limited edition · 1,200 pieces</p>
<h3 style="margin:0;font:700 1.6rem/1.15 var(--mv-font-sans);letter-spacing:-.02em">Lumen Automatic 40 mm</h3>
<p style="margin:0;color:var(--mv-fg-muted);line-height:1.6">Hover over the dial to read the fine print: power reserve, serial number, certification. With a keyboard, move the magnifier with the arrow keys.</p>
<mv-lens data-shape="square" zoom="2.6" size="120" label="Alpine landscape" style="border-radius:var(--mv-radius-lg)">
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 700'%3E%3Cdefs%3E%3ClinearGradient id='s' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%231e1b4b'/%3E%3Cstop offset='.45' stop-color='%236d28d9'/%3E%3Cstop offset='.78' stop-color='%23f472b6'/%3E%3Cstop offset='1' stop-color='%23fdba74'/%3E%3C/linearGradient%3E%3ClinearGradient id='a' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%237c3aed'/%3E%3Cstop offset='1' stop-color='%23312e81'/%3E%3C/linearGradient%3E%3ClinearGradient id='b' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%233b0764'/%3E%3Cstop offset='1' stop-color='%231e1b4b'/%3E%3C/linearGradient%3E%3ClinearGradient id='w' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0' stop-color='%23fb923c' stop-opacity='.85'/%3E%3Cstop offset='.5' stop-color='%239d174d' stop-opacity='.7'/%3E%3Cstop offset='1' stop-color='%231e1b4b'/%3E%3C/linearGradient%3E%3CradialGradient id='g'%3E%3Cstop offset='0' stop-color='%23fff7ed'/%3E%3Cstop offset='.3' stop-color='%23fde68a'/%3E%3Cstop offset='1' stop-color='%23fb923c' stop-opacity='0'/%3E%3C/radialGradient%3E%3C/defs%3E%3Crect width='1200' height='470' fill='url(%23s)'/%3E%3Ccircle cx='760' cy='420' r='210' fill='url(%23g)'/%3E%3Ccircle cx='760' cy='430' r='54' fill='%23fff7ed'/%3E%3Cpath d='M0 390 140 250l60 45 40-30 120-100 70 90 40-30 90 70 60-50 90 110 110-130 70 60 40-20 70 60 50-60 130 110 V480 H0Z' fill='url(%23a)'/%3E%3Cpath d='M360 165 395 195 380 205 415 222 440 200 420 180Z M820 230 850 258 840 266 870 280 890 262Z' fill='%23f5f3ff' opacity='.75'/%3E%3Cpath d='M0 480 120 390l140 60 140-100 160 110 140-70 150 80 150-90 200 80 V480 H0Z' fill='url(%23b)'/%3E%3Crect y='470' width='1200' height='230' fill='url(%23w)'/%3E%3Cg fill='%23fde68a'%3E%3Crect x='640' y='500' width='240' height='5' rx='2.5' opacity='.7'/%3E%3Crect x='680' y='530' width='160' height='4' rx='2' opacity='.5'/%3E%3Crect x='710' y='560' width='100' height='3' rx='1.5' opacity='.4'/%3E%3Crect x='735' y='590' width='50' height='3' rx='1.5' opacity='.3'/%3E%3C/g%3E%3Cpath d='M0 700 V610 Q180 570 360 625 T760 645 T1200 600 V700Z' fill='%230b0720'/%3E%3Cg fill='%230b0720'%3E%3Cpath d='M90 610 110 520 130 610Z M130 615 155 500 180 615Z M1040 600 1062 505 1084 600Z M1080 605 1100 530 1120 605Z'/%3E%3C/g%3E%3C/svg%3E" alt="Mountain lake at sunset" loading="lazy">
</mv-lens>
<p style="margin:0;font-size:.8rem;color:var(--mv-fg-subtle)">data-shape="square" · zoom 2.6×</p>
</div>
</div>API
Attributes
| Name | Type | Default | Description |
|---|---|---|---|
zoom | number | 2.5 | Magnification factor. |
size | number | 170 | Diameter (or side) of the magnifier in px. |
data-shape | circle | square | circle | Magnifier shape. |
static | boolean | Magnifier always visible, fixed at position. | |
position | string | 50,50 | “x,y” in % for static mode. |
label | string | Magnifier | Accessible name of the group. |
Methods
| Name | Description |
|---|---|
refresh() | Copies the content into the magnifier again after the children change. |
CSS classes
| Name | Description |
|---|---|
mv-lens-view / -zoom / -ring | Clipped layer, enlarged copy, rim (generated). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-lens-radius | var(--mv-radius-xl) | Frame radius. |
--mv-lens-rim | white 55% | Rim color. |
--mv-lens-backdrop | var(--mv-bg) | Background under the enlarged copy. |
Accessibility
Focusable group (aria-roledescription “magnifier”): on keyboard focus the magnifier appears, ←/→/↑/↓ move it (Shift: bigger steps), Escape closes it. The enlarged copy is aria-hidden and inert (no duplicated ids); the original content keeps its alt text. Touch: press and hold. Reduced motion: opening is instant.