A magnifying lens that follows the pointer over an image or any content, moves with the arrow keys when focused, hides on Escape and keeps the copy inside it out of the accessibility tree.
import { Lens } from "@/components/ballmac/lens"
function map() {
const lines = Array.from({ length: 18 }, (_, i) => `<path d="M0 ${i * 28 + 10} Q 120 ${i * 28 - 20}, 240 ${i * 28 + 14} T 480 ${i * 28 + 6}" stroke="rgba(255,255,255,.35)" fill="none" stroke-width="1.4"/>`).join("")
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#0ea5e9"/><stop offset="1" stop-color="#6366f1"/></linearGradient></defs><rect width="480" height="320" fill="url(#g)"/>${lines}<circle cx="300" cy="150" r="9" fill="#fff"/><circle cx="140" cy="210" r="6" fill="#fde047"/><text x="316" y="146" font-family="sans-serif" font-size="12" fill="#fff">Lisbon</text><text x="152" y="214" font-family="sans-serif" font-size="9" fill="#fff">Porto</text></svg>`
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`
}
export default function LensDemo() {
return (
<Lens zoom={2.6} lensSize={150} label="Route map" className="w-full max-w-md border shadow-sm">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={map()} alt="Map of the coast between Porto and Lisbon" className="block w-full select-none" draggable={false} />
</Lens>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/lensInstall the dependencies.
$ pnpm add motion@^12Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/lens.tsx// Ballmac UI: Lens. https://ui.ballmac.com/components/lens "use client" import * as React from "react" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type LensProps = Omit<React.ComponentProps<"div">, "children"> & { /** What to magnify: usually an image, but any visual content works. */ children: React.ReactNode /** How much the lens enlarges what is under it. */ zoom?: number /** Diameter of the lens in pixels. */ lensSize?: number /** Accessible name; the keyboard instructions are added for you. */ label?: string } const STEP = 24 function Lens({ children, zoom = 2.2, lensSize = 160, label, className, onPointerMove, onPointerLeave, onKeyDown, onFocus, onBlur, ...props }: LensProps) { const msg = useMessages() label ??= msg("lens.label", "Zoomable image") const reduce = useReducedMotion() const ref = React.useRef<HTMLDivElement>(null) const [pos, setPos] = React.useState<{ x: number; y: number } | null>(null) const [box, setBox] = React.useState({ w: 0, h: 0 }) const [mode, setMode] = React.useState<"pointer" | "keyboard" | null>(null) React.useEffect(() => { const el = ref.current if (!el) return const measure = () => setBox({ w: el.offsetWidth, h: el.offsetHeight }) measure() const ro = new ResizeObserver(measure) ro.observe(el) return () => ro.disconnect() }, []) const clamp = (x: number, y: number) => ({ x: Math.min(Math.max(x, 0), box.w), y: Math.min(Math.max(y, 0), box.h) }) return ( <div ref={ref} data-slot="lens" role="group" tabIndex={0} aria-label={msg("lens.moveThePointerOverIt", "{label}. Move the pointer over it, or use the arrow keys, to magnify. Escape hides the lens.", { label })} onPointerMove={(e) => { onPointerMove?.(e) if (e.pointerType === "touch") return const r = e.currentTarget.getBoundingClientRect() setMode("pointer") setPos(clamp(e.clientX - r.left, e.clientY - r.top)) }} onPointerLeave={(e) => { onPointerLeave?.(e) if (mode === "pointer") { setPos(null) setMode(null) } }} onFocus={(e) => { onFocus?.(e) if (e.currentTarget.matches(":focus-visible") && mode !== "pointer") { setMode("keyboard") setPos((p) => p ?? { x: box.w / 2, y: box.h / 2 }) } }} onBlur={(e) => { onBlur?.(e) if (mode === "keyboard") { setPos(null) setMode(null) } }} onKeyDown={(e) => { onKeyDown?.(e) const move: Record<string, [number, number]> = { ArrowLeft: [-STEP, 0], ArrowRight: [STEP, 0], ArrowUp: [0, -STEP], ArrowDown: [0, STEP] } if (e.key === "Escape") { setPos(null) setMode(null) } else if (move[e.key]) { e.preventDefault() setMode("keyboard") setPos((p) => { const base = p ?? { x: box.w / 2, y: box.h / 2 } return clamp(base.x + move[e.key]![0], base.y + move[e.key]![1]) }) } }} className={cn("relative inline-block cursor-zoom-in overflow-hidden rounded-xl outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className)} {...props} > {children} {pos && box.w > 0 && ( <motion.div aria-hidden="true" className="pointer-events-none absolute top-0 start-0 z-10 overflow-hidden rounded-full border-2 border-white/90 bg-background shadow-[0_12px_32px_-6px_rgb(0_0_0/0.45),inset_0_0_0_1px_rgb(0_0_0/0.15)]" style={{ width: lensSize, height: lensSize, x: pos.x - lensSize / 2, y: pos.y - lensSize / 2 }} initial={reduce ? false : { opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} transition={{ type: "spring", stiffness: 420, damping: 28 }} > {/* A second copy of the content, scaled up around the pointer. It is hidden from assistive tech and cannot be focused. */} <div inert className="absolute top-0 left-0 origin-top-left" // rtl-fixed: scaled from its top-left corner style={{ width: box.w, height: box.h, transform: `translate(${lensSize / 2 - pos.x * zoom}px, ${lensSize / 2 - pos.y * zoom}px) scale(${zoom})`, }} > {children} </div> <span className="absolute inset-0 rounded-full bg-[radial-gradient(120%_120%_at_30%_15%,rgb(255_255_255/0.28),transparent_45%)]" /> </motion.div> )} </div> ) } export { Lens, type LensProps }Update the import paths to match your project setup.
Usage
import { Lens } from "@/components/ballmac/lens"The full example is in the Code tab above.
Examples
Magnifying text
Terms of the offer
The free trial lasts 14 days and ends automatically. No payment details are needed to start. You may cancel at any time from your account settings, and your data is kept for 30 days after cancellation so you can export it.
import { Lens } from "@/components/ballmac/lens"
export default function LensText() {
return (
<Lens zoom={2} lensSize={140} label="Fine print" className="w-full max-w-sm border bg-card">
<div className="p-5">
<h3 className="text-sm font-semibold">Terms of the offer</h3>
<p className="mt-2 text-[10px] leading-4 text-muted-foreground">
The free trial lasts 14 days and ends automatically. No payment details are needed to start. You may cancel at any time from your account settings, and your data is kept for 30 days after cancellation so you can export it.
</p>
</div>
</Lens>
)
}API reference
| Prop | Type | Default |
|---|---|---|
children*What to magnify: usually an image, but any visual content works. | React.ReactNode | — |
zoomHow much the lens enlarges what is under it. | number | 2.2 |
lensSizeDiameter of the lens in pixels. | number | 160 |
labelAccessible name; the keyboard instructions are added for you. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | The area is focusable; the lens appears centered |
| ArrowKeys | Move the lens in steps; Escape hides it |
| Screen readers | The group is named and explains the keys; the copy is hidden |
Use with AI
<Lens zoom lensSize label>{image or content}</Lens>. Works with any child. The magnified copy is inert and aria-hidden. Touch pointers are ignored so scrolling is never blocked. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Lens (@ballmac/lens) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Product photos and maps
- Inspecting fine detail in diagrams or charts
Not for
- Full-screen image viewing (use a dialog)
Registry JSON: https://ui.ballmac.com/r/lens.json
Credits
Free to use in personal and commercial projects.
- npm
- motion@^12
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Tilt Card
A card that tilts in 3D toward the pointer with spring smoothing and a moving glare. TiltCardLayer children float at their own depth for parallax. Keyboard focus shows a gentle tilt.
Animated Beam
An SVG beam that connects two elements with a curved path and sends a glowing gradient pulse along it. Follows layout changes, pauses off-screen, static under reduced motion.
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.