Image Effects
HoverZoom
The picture enlarges inside its frame and pans with the pointer. Tap to zoom on a phone; arrow keys pan from the keyboard.
Download
- React
- Tailwind CSS v4
- motion
components/ui/hover-zoom.tsx
"use client";
import { useRef } from "react";
import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring } from "motion/react";
type HoverZoomProps = {
src: string;
alt: string;
/** How much the picture enlarges. */
zoom?: number;
/** Set the size here. */
className?: string;
};
const FOLLOW = { stiffness: 300, damping: 40 };
const GROW = { stiffness: 220, damping: 28 };
/* HoverZoom: the picture enlarges inside its frame and pans with the pointer. Tap to zoom on a phone; arrow keys pan from the keyboard. */
export function HoverZoom({ src, alt, zoom = 2, className = "aspect-video" }: HoverZoomProps) {
const reduce = useReducedMotion();
const start = useRef({ x: 0, y: 0 });
const x = useMotionValue(50);
const y = useMotionValue(50);
const level = useMotionValue(1);
const smoothX = useSpring(x, FOLLOW);
const smoothY = useSpring(y, FOLLOW);
const smoothLevel = useSpring(level, GROW);
const transformOrigin = useMotionTemplate`${reduce ? x : smoothX}% ${reduce ? y : smoothY}%`;
const aim = (e: React.PointerEvent<HTMLDivElement>) => {
const r = e.currentTarget.getBoundingClientRect();
x.set(((e.clientX - r.left) / r.width) * 100);
y.set(((e.clientY - r.top) / r.height) * 100);
};
const enter = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType !== "mouse") return;
aim(e);
smoothX.jump(x.get());
smoothY.jump(y.get());
level.set(zoom);
};
const down = (e: React.PointerEvent<HTMLDivElement>) => {
start.current = { x: e.clientX, y: e.clientY };
if (e.pointerType !== "mouse" && level.get() === 1) aim(e);
};
const move = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse" || level.get() > 1) aim(e);
};
const up = (e: React.PointerEvent<HTMLDivElement>) => {
const still = Math.hypot(e.clientX - start.current.x, e.clientY - start.current.y) < 10;
if (e.pointerType !== "mouse" && still) level.set(level.get() > 1 ? 1 : zoom);
};
const leave = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.pointerType === "mouse") level.set(1);
};
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
if (!e.currentTarget.matches(":focus-visible")) return;
x.set(50);
y.set(50);
level.set(zoom);
};
const onKey = (e: React.KeyboardEvent<HTMLDivElement>) => {
const step: Record<string, [number, number]> = { ArrowLeft: [-10, 0], ArrowRight: [10, 0], ArrowUp: [0, -10], ArrowDown: [0, 10] };
if (e.key === "Escape") level.set(1);
if (!(e.key in step)) return;
e.preventDefault();
level.set(zoom);
x.set(Math.max(0, Math.min(100, x.get() + step[e.key][0])));
y.set(Math.max(0, Math.min(100, y.get() + step[e.key][1])));
};
return (
<div
role="group"
aria-label={`${alt}. Arrow keys pan the enlarged picture.`}
tabIndex={0}
onPointerEnter={enter}
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerLeave={leave}
onFocus={onFocus}
onBlur={() => level.set(1)}
onKeyDown={onKey}
className={`relative cursor-zoom-in touch-pan-y select-none overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`}
>
<motion.img
src={src}
alt={alt}
draggable={false}
className="absolute inset-0 size-full object-cover"
style={{ scale: reduce ? level : smoothLevel, transformOrigin }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/hover-zoom.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/hover-zoom-demo.tsx
import { HoverZoom } from "@/components/ui/hover-zoom"; export default function HoverZoomExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-4"> <HoverZoom src="/demo/ember.jpg" alt="Red hills under a burning sky" 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 | 2 | How much the picture enlarges. |
| className | string | "aspect-video" | Set the size here. |