Image Effects
FocusLens
The picture is sharp at rest. With the mouse, a finger or the arrow keys, the rest goes soft and a round lens shows that spot enlarged.
Download
- React
- Tailwind CSS v4
- motion
components/ui/focus-lens.tsx
"use client";
import { useRef, useState } from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
type FocusLensProps = {
src: string;
alt: string;
/** How much the lens enlarges. */
zoom?: number;
/** Lens radius in pixels. */
radius?: number;
/** Blur on the rest of the picture while the lens is out, in pixels. */
blur?: number;
/** Set the size here. */
className?: string;
};
const FOLLOW = { stiffness: 600, damping: 45 };
const POP = { stiffness: 320, damping: 26 };
/* FocusLens: the picture is sharp at rest. With the mouse, a finger or the arrow keys, the rest goes soft and a round lens shows that spot enlarged. */
export function FocusLens({ src, alt, zoom = 1.8, radius = 80, blur = 4, className = "aspect-video" }: FocusLensProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const smoothX = useSpring(x, FOLLOW);
const smoothY = useSpring(y, FOLLOW);
const lensX = reduce ? x : smoothX;
const lensY = reduce ? y : smoothY;
const size = useMotionValue(0);
const smoothSize = useSpring(size, POP);
const lensSize = reduce ? size : smoothSize;
const clipSize = useTransform(lensSize, (v) => Math.max(0, v) / zoom);
const ringSize = useTransform(lensSize, (v) => Math.max(0, v) * 2);
const clipPath = useMotionTemplate`circle(${clipSize}px at ${lensX}px ${lensY}px)`;
const transformOrigin = useMotionTemplate`${lensX}px ${lensY}px`;
const [on, setOn] = useState(false);
const show = (px: number, py: number) => {
x.jump(px);
y.jump(py);
smoothX.jump(px);
smoothY.jump(py);
size.set(radius);
setOn(true);
};
const hide = () => {
size.set(0);
setOn(false);
};
const enter = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
show(e.clientX - r.left, e.clientY - r.top);
};
const move = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
x.set(e.clientX - r.left);
y.set(e.clientY - r.top);
};
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
if (!e.currentTarget.matches(":focus-visible")) return;
show(e.currentTarget.offsetWidth / 2, e.currentTarget.offsetHeight / 2);
};
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step: Record<string, [number, number]> = { ArrowLeft: [-20, 0], ArrowRight: [20, 0], ArrowUp: [0, -20], ArrowDown: [0, 20] };
const box = boxRef.current;
if (e.key === "Escape") hide();
if (!(e.key in step) || !box) return;
e.preventDefault();
if (!on) show(box.offsetWidth / 2, box.offsetHeight / 2);
x.set(Math.max(0, Math.min(box.offsetWidth, x.get() + step[e.key][0])));
y.set(Math.max(0, Math.min(box.offsetHeight, y.get() + step[e.key][1])));
};
return (
<div
ref={boxRef}
role="group"
aria-label={`${alt}. Arrow keys move a magnifying lens.`}
tabIndex={0}
onPointerEnter={enter}
onPointerDown={enter}
onPointerMove={move}
onPointerLeave={hide}
onFocus={onFocus}
onBlur={hide}
onKeyDown={onKey}
className={`relative touch-pan-y select-none overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}
>
{/* eslint-disable @next/next/no-img-element */}
<img
src={src}
alt={alt}
draggable={false}
className="absolute inset-0 size-full object-cover transition-[filter,scale] duration-300 motion-reduce:transition-none"
style={{ filter: on ? `blur(${blur}px) brightness(0.72)` : "none", scale: on ? 1.04 : 1 }}
/>
<motion.img
src={src}
alt=""
draggable={false}
className="pointer-events-none absolute inset-0 size-full object-cover"
style={{ clipPath, transformOrigin, scale: zoom }}
/>
{/* eslint-enable @next/next/no-img-element */}
<motion.span className="pointer-events-none absolute top-0 left-0" style={{ x: lensX, y: lensY }}>
<motion.span
className="absolute -translate-x-1/2 -translate-y-1/2 rounded-full shadow-[inset_0_0_0_1.5px_rgba(255,255,255,0.8),0_8px_30px_rgba(0,0,0,0.45)]"
style={{ width: ringSize, height: ringSize }}
/>
</motion.span>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/focus-lens.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/focus-lens-demo.tsx
import { FocusLens } from "@/components/ui/focus-lens"; export default function FocusLensExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <FocusLens src="/demo/dusk.jpg" alt="Hills at dusk under a pale sun" className="aspect-video w-full max-w-[520px] rounded-2xl" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. |
| alt | string | — | Text for screen readers. |
| zoom | number | 1.8 | How much the lens enlarges. |
| radius | number | 80 | Lens radius in pixels. |
| blur | number | 4 | Blur on the rest of the picture while the lens is out, in pixels. |
| className | string | "aspect-video" | Set the size here. |