Hover Zoom & Pan
Hovering zooms the image in, and moving the pointer pans across it, so shoppers can inspect detail without opening a viewer.
Create a hover zoom-and-pan image in React and CSS. The frame has overflow hidden and a fixed aspect ratio; the image inside fills it. On pointermove, set transform-origin on the image to the pointer position in percent, and on pointerenter scale the image to 2 with a 300ms cubic-bezier(0.16, 1, 0.3, 1) transform transition. Keep transform-origin untransitioned, or give it only a short 80ms transition, so panning follows the pointer closely. Scale back to 1 on pointerleave. Use cursor: zoom-in. Make the frame focusable and zoom to the centre on :focus-visible for keyboard users. Under prefers-reduced-motion, zoom without the transition. The caption reads "Desert dunes, 2× detail".
Source
'use client'
import s from './demo.module.css'
export default function HoverZoomPan({ labels }: { labels: Record<string, string> }) {
const onMove = (e: React.PointerEvent<HTMLElement>) => {
const r = e.currentTarget.getBoundingClientRect()
const scene = e.currentTarget.firstElementChild as HTMLElement
scene.style.transformOrigin = `${((e.clientX - r.left) / r.width) * 100}% ${((e.clientY - r.top) / r.height) * 100}%`
}
return (
<figure className={s.frame} tabIndex={0} onPointerMove={onMove}>
<div className={s.scene} role="img" aria-label={labels.caption} />
<figcaption className={s.caption}>{labels.caption}</figcaption>
</figure>
)
}
.frame {
--sky: oklch(80% 0.07 70);
--dune-1: oklch(68% 0.13 55);
--dune-2: oklch(56% 0.13 45);
--dune-3: oklch(44% 0.11 40);
--sun: oklch(95% 0.05 85);
position: relative;
width: min(300px, 100%);
aspect-ratio: 16 / 10;
margin: 0;
overflow: hidden;
border-radius: 14px;
cursor: zoom-in;
}
.frame:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.scene {
position: absolute;
inset: 0;
background:
radial-gradient(ellipse 60% 30% at 70% 100%, var(--dune-3) 98%, transparent 100%),
radial-gradient(ellipse 70% 34% at 20% 92%, var(--dune-2) 98%, transparent 100%),
radial-gradient(ellipse 80% 40% at 60% 80%, var(--dune-1) 98%, transparent 100%),
repeating-linear-gradient(170deg, transparent 0 6px, oklch(100% 0 0 / 0.06) 6px 7px),
radial-gradient(circle at 26% 30%, var(--sun) 0 7%, transparent 7.5%),
linear-gradient(var(--sky), var(--dune-1));
transition: transform 300ms var(--ease-out), transform-origin 80ms linear;
}
.frame:hover .scene { transform: scale(2); }
.frame:focus-visible .scene { transform: scale(2); transform-origin: center; }
.caption {
position: absolute;
left: 10px;
bottom: 8px;
padding: 3px 8px;
border-radius: 6px;
background: oklch(0% 0 0 / 0.45);
color: var(--stage-ink);
font: 500 var(--text-xs) / 1.3 var(--font-body);
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.scene { transition: none; }
}