How to Zoom and Rotate an Image in React
Build accessible React image zoom and rotation controls with CSS transforms, then add panning or pinch gestures when your viewer needs them.
To zoom and rotate an image in React, keep its scale and rotation angle in component state, then apply both in one CSS transform. Use real buttons for keyboard-accessible controls and meaningful alt text for the image. Add panning or pinch gestures only when the viewer needs them; those interactions require keeping translation, scale, and rotation coordinated.
1. Build a React image viewer with zoom and rotation
This complete component uses React state and CSS transforms. The controls zoom in, zoom out, rotate clockwise, and reset. The scale is bounded so repeated clicks cannot reduce the image to zero or grow it without limit.
import { useState } from 'react';
import './ImageViewer.css';
const MIN_SCALE = 0.5;
const MAX_SCALE = 4;
const SCALE_STEP = 0.25;
const ROTATION_STEP = 90;
export default function ImageViewer({ src, alt }) {
const [scale, setScale] = useState(1);
const [rotation, setRotation] = useState(0);
function zoomBy(amount) {
setScale(current => Math.min(MAX_SCALE, Math.max(MIN_SCALE, current + amount)));
}
function rotateClockwise() {
setRotation(current => (current + ROTATION_STEP) % 360);
}
function reset() {
setScale(1);
setRotation(0);
}
return (
<section className="image-viewer" aria-label="Image viewer">
<div className="image-viewer__viewport">
<img
className="image-viewer__image"
src={src}
alt={alt}
style={{ transform: `scale(${scale}) rotate(${rotation}deg)` }}
/>
</div>
<div className="image-viewer__controls" aria-label="Image controls">
<button type="button" onClick={() => zoomBy(SCALE_STEP)} disabled={scale >= MAX_SCALE}>
Zoom in
</button>
<button type="button" onClick={() => zoomBy(-SCALE_STEP)} disabled={scale <= MIN_SCALE}>
Zoom out
</button>
<button type="button" onClick={rotateClockwise}>Rotate clockwise</button>
<button type="button" onClick={reset}>Reset</button>
<span aria-live="polite">{Math.round(scale * 100)}% zoom, {rotation}° rotation</span>
</div>
</section>
);
}
/* ImageViewer.css */
.image-viewer__viewport {
display: grid;
place-items: center;
width: 100%;
height: min(70vh, 640px);
overflow: hidden;
background: #171717;
}
.image-viewer__image {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
transform-origin: center;
transition: transform 160ms ease;
will-change: transform;
}
.image-viewer__controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding-top: 0.75rem;
}
@media (prefers-reduced-motion: reduce) {
.image-viewer__image { transition: none; }
}
Use it with an image URL and a useful description, for example <ImageViewer src="/photos/map.jpg" alt="A street map showing the route through the city" />. React’s image reference describes src as the image URL and alt as its text description. React DOM components support pointer event handlers as well, which are useful if you extend this viewer with dragging.
Why put scale and rotation in one transform?
A CSS element has one computed transform property. If separate handlers assign transforms independently, the later assignment can overwrite the earlier one. Store each transform component in state and build the complete transform string from that state. The order matters: scale(...) rotate(...) and rotate(...) scale(...) can yield different results when translation or nonuniform scaling is involved.
This example scales before rotating. With a centered transform origin and uniform scale, it is a straightforward choice for a basic viewer. If you add panning, test the chosen order with the expected drag behavior and keep all transform values in the same state update path.
2. Add panning and pointer dragging
Zooming makes portions of a large image extend beyond the viewport. Users then need a way to move the image. Add translation state and apply it in the same transform declaration. The following extension uses pointer events for mouse, pen, and touch dragging; it is intended for a single-pointer drag, not pinch zoom.
import { useRef, useState } from 'react';
export function PannableImage({ src, alt }) {
const [view, setView] = useState({ x: 0, y: 0, scale: 1, rotation: 0 });
const drag = useRef(null);
function onPointerDown(event) {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
drag.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY,
originX: view.x, originY: view.y };
}
function onPointerMove(event) {
const start = drag.current;
if (!start || start.pointerId !== event.pointerId) return;
setView(current => ({ ...current,
x: start.originX + event.clientX - start.startX,
y: start.originY + event.clientY - start.startY }));
}
function stopDrag(event) {
if (drag.current?.pointerId === event.pointerId) drag.current = null;
}
return (
<div className="image-viewer__viewport image-viewer__draggable">
<img src={src} alt={alt} draggable="false"
onPointerDown={onPointerDown} onPointerMove={onPointerMove}
onPointerUp={stopDrag} onPointerCancel={stopDrag}
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.scale}) rotate(${view.rotation}deg)` }} />
</div>
);
}
.image-viewer__draggable { touch-action: none; }
.image-viewer__draggable img { cursor: grab; user-select: none; }
.image-viewer__draggable img:active { cursor: grabbing; }
This excerpt shows the drag state and handlers; combine them with the zoom and rotation controls from the first component to expose all operations. Pointer capture keeps move and release events associated with the dragged image even when the pointer leaves its bounds. Handle pointercancel as well as pointerup so an interrupted gesture does not leave dragging active.
touch-action: none lets the viewer own touch gestures in that region, but also prevents the browser’s usual touch scrolling there. Use it only if that behavior fits the page. For a production viewer, also decide whether to clamp translation to the image bounds or allow users to pan the image entirely out of view, and reset or recenter after rotation when that produces a better experience.
3. Support wheel, pinch, and bounded panning with a library
For a viewer that needs wheel zoom, pinch gestures, drag panning, bounds, callbacks, and built-in controls, a zoom and pan library can save interaction work. react-zoom-pan-pinch documents TransformWrapper and TransformComponent around an image and exposes zoom, reset, scale, and position controls. Its reviewed 4.0.3 declarations do not show a dedicated rotation property or handler, so implement rotation separately and verify how it composes with the library wrapper and styles. Check the API for the version installed in your project because declarations and package APIs can change.
import { useState } from 'react';
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
export function LibraryImageViewer({ src, alt }) {
const [rotation, setRotation] = useState(0);
return (
<section aria-label="Image viewer">
<div>
<button type="button" onClick={() => setRotation(value => (value + 90) % 360)}>
Rotate clockwise
</button>
<button type="button" onClick={() => setRotation(0)}>Reset rotation</button>
</div>
<TransformWrapper>
<TransformComponent>
<img src={src} alt={alt} style={{ transform: `rotate(${rotation}deg)` }} />
</TransformComponent>
</TransformWrapper>
</section>
);
}
The wrapper owns zoom and pan while React state owns rotation. Because transforms on nested elements compose through the DOM, rotating the image inside the library’s transformed content can work, but behavior depends on wrapper sizing, transform origins, and bounds. Validate those interactions in your app. The cited library material documents pinch, wheel, panning, and bounds; it does not establish a built-in rotation control or provide comparative performance measurements.
4. Choose the right implementation
| Need | Custom state and CSS | Zoom and pan library |
|---|---|---|
| Buttons for zoom and rotation | Direct control; small implementation | May add more API and configuration than needed |
| Wheel, pinch, and drag | You own event handling and gesture details | Documented interaction support is available |
| Pan bounds and reset behavior | You define and maintain the rules | Library exposes positioning and reset controls; configure and verify behavior |
| Rotation composition | All transforms are explicit in one place | Rotation needs separate handling in the reviewed API; check wrapper composition |
| Accessibility and keyboard behavior | Use buttons and add keyboard/pan alternatives intentionally | Verify controls and keyboard behavior for your app’s needs |
| Dependencies and maintenance | No interaction dependency, but more code is yours to maintain | Adds a dependency and an API surface; verify version and upgrade needs |
There are no comparative benchmark or bundle-size measurements in the cited material. Choose based on the interactions you need and the code your team wants to own.
5. Edge cases and accessibility
- Rotation changes the visible bounds. A portrait image rotated to landscape, or the reverse, may no longer fit the viewport. Keep the viewport clipped, allow panning, or recalculate the fitted scale after rotation.
- Bound zoom. Set minimum and maximum scales and disable controls at those limits. Consider a fit-to-viewport scale as the reset target when image dimensions vary.
- Keyboard use. Use native
buttonelements with descriptive names. If you add keyboard shortcuts, document them and avoid capturing keys while focus is in text inputs. - Alternative text. Preserve meaningful
alttext. If the image is decorative and nearby content conveys the same information, use an empty alternative instead. - Reduced motion. Respect
prefers-reduced-motionby shortening or removing transform transitions. - Touch scrolling. A gesture surface that uses
touch-action: nonecan prevent page scrolling over the image. Scope it to the viewer and make the tradeoff intentional. - Image loading and layout. Reserve a viewport size so the page does not jump when the image loads. Handle missing or failed image URLs with a visible fallback when the viewer is part of a critical workflow.
- Large images. CSS transforms change visual size but do not reduce the decoded source image’s memory use. Prefer appropriately sized image assets for the expected viewport.
6. Performance, reliability, and cost
For button-driven transforms, updating a few numeric state values and changing CSS is generally simple. Keep the image element mounted and update its transform rather than repeatedly replacing the source. A transform transition can make changes easier to follow, but large images still have decode and memory costs based on their source dimensions. The research sources provide no benchmark figures, so profile the actual devices and image sizes your application supports.
Reliability depends on keeping transform state coordinated. Use functional state updates when a new value depends on the previous one, as in the examples, so rapid clicks do not rely on stale values. Reset translation along with scale and angle if your viewer promises a full reset. For gestures, clear drag state on both pointer release and cancellation. Costs are mainly engineering and maintenance time: a custom implementation avoids an interaction dependency but leaves gesture and bounds behavior to your code; a library adds a dependency and versioned API to maintain. No paid service is required for the in-browser React implementation.
7. Or skip the browser setup
If the task is to capture a webpage as an image before inspecting or sharing it, ScreenshotNeo provides a website screenshot API and MCP server. For an interactive React viewer, the CSS and state approach above remains the way to implement zoom and rotation in your app.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
Python:
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
Node.js:
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
See the ScreenshotNeo API documentation for the request parameters. Cookie banners, popups, and chat widgets are removed before the shot; bot checks, blank pages, and failed loads are never billed. Its MCP server lets AI agents take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.
Sign up for 1,000 free screenshots a month, with no card.
8. Frequently asked questions
How do I rotate an image in React?
Store the angle in state and include rotate(${angle}deg) in the image’s composed CSS transform. A button can increase the angle by 90 degrees and wrap it back to 0 after 360.
How do I zoom an image in React?
Store a scale value in state and include scale(${scale}) in the same transform string. Clamp the value to a minimum and maximum that fit your viewer.
Does react-zoom-pan-pinch provide rotation?
The reviewed README and 4.0.3 type declarations establish zoom and pan controls but do not show a dedicated rotation API. Add rotation separately and verify the result with the installed version.
Should I rotate the image file itself?
For a viewer interaction, a CSS transform changes the displayed orientation without replacing the source asset. If you need a permanently edited image file, that is a separate image-processing operation.


