A frame that zooms whatever is inside it — wheel, trackpad pinch, double click or the buttons — and lets you drag it around once it no longer fits. Reach for it wherever a picture is too detailed to read at the size it is shown: product and listing photography where the buyer wants the fabric, the stitching or the serial number, floor plans and architectural drawings, engineering and CAD schematics, circuit diagrams, maps and site plans shipped as images, satellite and aerial imagery, medical scans and microscopy, screenshots in documentation and bug reports, design and artwork review, scanned documents and receipts, org charts and flow diagrams, dense data visualisations, archive and museum photography, and any figure in an article that is legible on a desktop and unreadable on a phone. Common asks it answers: "image zoom react", "pan and zoom component", "pinch to zoom image", "zoom into image on scroll", "wheel zoom image react", "draggable zoomable image", "zoom and drag image", "react-zoom-pan-pinch alternative", "medium-zoom alternative", "panzoom alternative", "image viewer component", "zoomable diagram", "magnify image component", "shadcn image zoom", "shadcn pan zoom". Official shadcn/ui has nothing for this and no combination of its parts reaches it: aspect-ratio only holds a box at a shape, carousel moves between pictures rather than into one, dialog can present an image larger but cannot magnify part of it, and scroll-area scrolls content it never scales. Distinct from image-comparison, which slides between two pictures of the same thing, and from image-crop, which chooses a region to save — this one changes nothing and only alters how closely you are looking. Pairs with fullscreen-button, which is how the frame gets big enough for zooming to pay off: go full screen first, then zoom in. The component turns on one piece of arithmetic: whatever is under the pointer stays under the pointer. Applying scale() about the centre of the frame is the one-line version everybody writes, and it is useless on anything worth zooming — the detail you aimed at slides toward the edge as you go in, faster the further from the middle it started, so reading a label becomes a game of zoom-then-drag-it-back. Here the translation is re-solved for each new scale so the anchor holds, and the scale is clamped before the translation is solved rather than after, which is what stops the picture lurching sideways on every wheel notch past the maximum. Four more things it settles that are invisible until they are wrong. The wheel listener is registered by hand with passive: false, because React attaches onWheel passively in several browsers and a passive listener's preventDefault is silently ignored — the frame zooms while the page scrolls out from under it. A trackpad pinch arrives as a wheel event with ctrlKey set rather than as a touch gesture, so a component written only for touch does nothing at all on a laptop; both paths run through one handler here. Wheel deltas are read with deltaMode, because Chrome and Safari report pixels while Firefox reports lines — ignoring it makes the zoom feel right in one browser and roughly sixteen times too slow in the other. And the steps are exponential rather than linear, so scrolling in and back out lands on the scale you started from instead of creeping smaller all afternoon. Everything the pointer can do has a keyboard form, because none of these gestures have one on their own: the frame is a tab stop, + and - zoom about its centre, the arrow keys pan, and 0 resets. The arrows are left to the page when there is nothing to pan, and touch-action is pan-y while the content fits and none once it does not, so a full-width frame is never something you cannot scroll past on a phone. The controls are labelled buttons marked aria-disabled rather than disabled — a real disabled drops them out of the tab order the moment you reach a limit — and they stay silent instead of reporting a change that did not happen. The zoom level is shown as a percentage in a polite live region, so it is announced rather than left as a visual-only state. Pan is clamped against the content's own unzoomed size rather than the frame's, so a letterboxed picture cannot be dragged half out of the box, and the frame re-clamps when it is resized, which is what a phone rotated while zoomed in needs. Uncontrolled by default (defaultTransform) or controlled by passing transform with onTransformChange. The maths is exported too — zoomAt, clampPan, clampScale and contentPointAt — so an annotation layer can map a click back to a point on the unzoomed image without reimplementing any of it. Styled entirely with shadcn tokens (border, background, muted, ring, accent), so it follows light and dark mode, and it ships zero dependencies — no Radix, no icon package, one file.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/image-zoom.json"