Why Photos Show Up Sideways, and How EXIF Orientation Works
A photo looks right in your phone’s gallery. You upload it to a form, a forum or a CMS, and it shows up lying on its side. You rotate it on the phone, upload it again, and nothing changes. The photo isn’t broken. It was stored sideways on purpose, with a note saying how to turn it, and the site you uploaded it to ignored the note. When I built Rotate image, most of the work went into handling that note correctly.
The photo is stored sideways on purpose
A phone camera’s sensor has a fixed “up”. When you hold the phone in portrait, the sensor is lying on its side, and the pixels come out lying on their side too. Many cameras don’t turn the pixels before saving. They save them as the sensor saw them and add an EXIF tag, Orientation (0x0112), that tells viewers how to turn the picture before showing it.
Phone galleries and current browsers read the tag, so the photo looks right there. Anything that reads only the pixels shows it the way the sensor saw it.
The eight orientation values
The tag holds a number from 1 to 8. Each describes one of the eight ways a rectangle can be turned and mirrored. Written as “mirror first, then turn clockwise”, they are:
| Value | What the viewer must do | Mirror | Turn |
|---|---|---|---|
| 1 | Nothing, already upright | no | 0° |
| 2 | Mirror left to right | yes | 0° |
| 3 | Turn 180° | no | 180° |
| 4 | Mirror top to bottom | yes | 180° |
| 5 | Mirror, then turn 270° clockwise | yes | 270° |
| 6 | Turn 90° clockwise | no | 90° |
| 7 | Mirror, then turn 90° clockwise | yes | 90° |
| 8 | Turn 270° clockwise (90° left) | no | 270° |
Photos from phone cameras normally use 1, 3, 6 or 8. The mirrored values are rare, but a correct reader still has to handle them.
Why some apps get it wrong
There are two common ways a photo ends up sideways:
- The app ignores the tag. Older software, some image libraries and some upload pipelines read the raw pixels and never look at EXIF.
- The tag is removed without turning the pixels. Many sites strip metadata from uploads, which is good for privacy. If they strip it before applying the orientation, the instruction is lost and the sideways pixels are all that’s left.
Rotating the photo on your phone often doesn’t help, because the gallery may change only the tag again. The fix that survives every app is to turn the pixels themselves and save a file that needs no tag at all. That’s what the tool does.
Eight states and one canonical form
The buttons are “Rotate left”, “Rotate right”, “Flip horizontally” and “Flip vertically”, and you can press them in any order. Storing the list of presses would mean replaying it every time. Instead, utils/orient.ts keeps one small state:
export interface Orientation { rotate: 0 | 90 | 180 | 270; flip: boolean }
Four turns times mirrored or not gives exactly eight states, the same eight as the EXIF table above. And it uses the same order: mirror left to right first, then turn clockwise. Any sequence of button presses collapses into one of them.
Rotating is just adding or taking away 90°. Flipping is the interesting part, because the buttons act on the image as you see it right now, which may already be turned:
// Mirroring what's on screen: H·R(r) = R(−r)·H, and a vertical mirror is a horizontal one turned 180°
export const flipHorizontal = (o: Orientation): Orientation => ({ rotate: turn(-o.rotate), flip: !o.flip })
export const flipVertical = (o: Orientation): Orientation => ({ rotate: turn(180 - o.rotate), flip: !o.flip })
The comment is the whole idea. Mirroring an image that was turned by r gives the same result as mirroring first and turning by −r. So a horizontal flip negates the turn and toggles the mirror. A vertical flip is a horizontal flip followed by a half turn, so it becomes 180° − r.
It’s the kind of code that looks right and is easy to get wrong, so the tests don’t trust the formulas. They draw a tiny 3 × 2 grid of letters with a reference renderer and check, from several starting states, that each button changes the rendered grid exactly as pressing it on screen would. They also check that four right turns bring you back to the original, and that flipping horizontally and then vertically equals a plain 180° turn.
The same state drives the status line under the buttons. It describes the result in the order people press buttons, such as “Rotated 90° right, then flipped horizontally”, and “Reset” goes back to “Original orientation”.
Drawing it on a canvas
The pixels are turned with a canvas. This is the core of composables/image/useRotate.ts:
// Canvas transforms apply last-to-first: mirror, then rotate, then move to the centre
ctx.translate(out.width / 2, out.height / 2)
ctx.rotate((o.rotate * Math.PI) / 180)
if (o.flip) ctx.scale(-1, 1)
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(bmp, -size.width / 2, -size.height / 2, size.width, size.height)
Canvas transforms are applied to the drawing in the reverse order of the calls. So the image is mirrored first, then turned, then moved so its center sits in the middle of the canvas, which matches the canonical order exactly. For a quarter turn, the canvas is created with width and height swapped, so a 4000 × 3000 photo becomes 3000 × 4000.
Before this runs, the image is decoded with createImageBitmap, and here the original EXIF tag matters again. The code relies on the browser applying the photo’s own orientation while decoding, as current browsers do. That way the preview matches what you see in your gallery, and your turn comes on top of it. The file the canvas produces has no EXIF at all, so there’s no tag left for another app to misread, and none of the original’s hidden data such as the GPS location.
Two limits come from the canvas itself:
- Size. Browsers refuse very large canvases, and iOS draws nothing instead of failing. The tool keeps the output within 16,777,216 pixels (the area of 4096 × 4096), scaling bigger photos down and saying so next to the file.
- Speed. Every image is redrawn each time you press a button, 120 ms after the last press, so quick clicks don’t start a redraw each.
Quality, formats and the Safari exception
Each image keeps its format where the browser can write it:
- PNG stays PNG and is lossless, so turning it costs nothing.
- JPG and WebP are saved again at quality 0.92. That isn’t visible in normal use. Each redraw starts again from your original file, so the download has been through one encoding, however many times you pressed the buttons.
- Other formats, such as GIF, are saved as JPG.
A lossless JPEG rotation does exist. Tools like jpegtran rearrange the compressed blocks without decoding them. Browsers don’t offer that, so a canvas tool has to encode again. At 0.92, done once, I think that’s the right trade.
Safari, and every browser on iPhone and iPad, can’t encode WebP. When the canvas is asked for WebP there, it quietly returns a PNG instead. The code checks the type of what it got back, names the file .png to match, and tells you why:
Your browser can’t save WebP, so it was saved as a PNG.
So you never download a file whose extension lies about what’s inside.
Try it
Rotate image turns and mirrors one image or a whole batch, with the same change applied to every file, and downloads them one by one or as a ZIP. Everything happens in your browser. For more ways to fix up photos, see the image tools.