I wanted a quick way to hide a face, license plate, or line of private text before sharing an image. So I built Pixelate Photo, a small Next.js tool that processes images locally.
The product itself is simple:
- Open an image.
- Pixelate the whole image or select specific areas.
- Adjust the pixel size.
- Download the result.
The interesting part was making the preview, selection tools, and exported image all use the same rendering logic.
In this article, I’ll focus on that pipeline.
The architecture
The application uses:
- Next.js with the App Router
- React and TypeScript
- The Canvas API for image processing
-
ImageBitmapfor decoding images - A React reducer for editor history
The page content can stay server-rendered, but the editor needs state, pointer events, and browser APIs such as createImageBitmap() and <canvas>.
That makes the editor a Client Component:
"use client"
import { useReducer, useRef } from "react"
export default function ImagePixelator() {
// Interactive editor logic lives here.
}
I kept the "use client" boundary around the editor instead of turning the entire page into a Client Component. The landing-page content, metadata, and other static sections do not need client-side JavaScript.
The important architectural decision was to keep the actual image-processing code outside the React components:
components/editor/
ImagePixelator.tsx
ImageCanvas.tsx
lib/canvas/
load-image.ts
pixelate.ts
export-image.ts
types.ts
React manages interaction. The canvas modules manage pixels.
That separation became especially useful when I needed the preview and downloaded image to produce the same result.
Step 1: Load the image into an ImageBitmap
The browser gives us a File after the user selects or pastes an image. Instead of creating an <img> element and waiting for it to load, I decode the file with createImageBitmap():
export async function loadImageFile(file: File) {
const allowedTypes = new Set([
"image/jpeg",
"image/png",
"image/webp",
])
if (!allowedTypes.has(file.type)) {
throw new Error("Use a JPG, PNG, or WebP image.")
}
const bitmap = await createImageBitmap(file, {
imageOrientation: "from-image",
})
if (bitmap.width < 1 || bitmap.height < 1) {
bitmap.close()
throw new Error("Could not read this image.")
}
return {
bitmap,
width: bitmap.width,
height: bitmap.height,
}
}
ImageBitmap works well here because it can be drawn directly onto a canvas:
context.drawImage(bitmap, 0, 0)
The image stays in browser memory. There is no upload endpoint and no server-side image-processing job.
That is not just a privacy feature. It also simplifies the architecture: no storage, upload progress, cleanup job, or image-processing service is needed.
Step 2: Create the pixelation effect
The pixelation algorithm is smaller than it might seem.
The basic trick is:
- Draw the source image onto a very small canvas.
- Scale that small canvas back up.
- Disable image smoothing while scaling up.
Here is a simplified version:
function renderMosaic(
source: CanvasImageSource,
sourceWidth: number,
sourceHeight: number,
outputWidth: number,
outputHeight: number,
pixelSize: number,
) {
const blocksX = Math.max(
1,
Math.round(sourceWidth / pixelSize),
)
const blocksY = Math.max(
1,
Math.round(sourceHeight / pixelSize),
)
const smallCanvas = document.createElement("canvas")
smallCanvas.width = blocksX
smallCanvas.height = blocksY
const smallContext = smallCanvas.getContext("2d")
if (!smallContext) {
throw new Error("Could not create canvas context.")
}
smallContext.imageSmoothingEnabled = true
smallContext.drawImage(
source,
0,
0,
sourceWidth,
sourceHeight,
0,
0,
blocksX,
blocksY,
)
const outputCanvas = document.createElement("canvas")
outputCanvas.width = outputWidth
outputCanvas.height = outputHeight
const outputContext = outputCanvas.getContext("2d")
if (!outputContext) {
throw new Error("Could not create canvas context.")
}
outputContext.imageSmoothingEnabled = false
outputContext.drawImage(
smallCanvas,
0,
0,
blocksX,
blocksY,
0,
0,
outputWidth,
outputHeight,
)
return outputCanvas
}
If the source image is 1600 pixels wide and pixelSize is 16, the temporary image is roughly 100 pixels wide.
When that tiny version is stretched back to 1600 pixels with smoothing disabled, every source pixel becomes a visible block.
A larger pixelSize creates fewer source blocks and therefore a stronger effect.
Step 3: Store selections in image coordinates
Pixelating the whole image is easy. Pixelating only a face or license plate is the more interesting problem.
The preview canvas changes size depending on the viewport and zoom level. If I stored selections in screen coordinates, they would move or resize whenever the preview changed.
Instead, every selection is stored in the coordinate system of the original image:
type Point = {
x: number
y: number
}
type RectSelection = {
id: string
kind: "rect"
x: number
y: number
width: number
height: number
}
type BrushSelection = {
id: string
kind: "brush"
size: number
points: Point[]
}
type Selection = RectSelection | BrushSelection
Pointer coordinates must therefore be converted from the displayed canvas back into image coordinates:
function toImagePoint(
clientX: number,
clientY: number,
canvas: HTMLCanvasElement,
imageWidth: number,
imageHeight: number,
): Point {
const rect = canvas.getBoundingClientRect()
const x =
((clientX - rect.left) / rect.width) * imageWidth
const y =
((clientY - rect.top) / rect.height) * imageHeight
return {
x: Math.min(imageWidth, Math.max(0, x)),
y: Math.min(imageHeight, Math.max(0, y)),
}
}
This is one of the most important details in the editor.
A rectangle drawn over a face is saved relative to the image, not the current screen. The same selection can then be rendered correctly in:
- A small responsive preview
- A zoomed preview
- The full-resolution exported image
Step 4: Use a mask for selected-area pixelation
For selected-area mode, I first render the complete mosaic onto an offscreen canvas.
Then I create a second canvas that acts as a mask:
- Selected pixels are opaque.
- Everything else is transparent.
Rectangles are drawn with fillRect(). Brush strokes are drawn as thick lines with rounded ends:
function drawSelections(
context: CanvasRenderingContext2D,
selections: Selection[],
scaleX: number,
scaleY: number,
) {
context.fillStyle = "#000"
context.strokeStyle = "#000"
context.lineCap = "round"
context.lineJoin = "round"
for (const selection of selections) {
if (selection.kind === "rect") {
context.fillRect(
selection.x * scaleX,
selection.y * scaleY,
selection.width * scaleX,
selection.height * scaleY,
)
continue
}
const points = selection.points
if (points.length === 0) continue
context.lineWidth =
selection.size * ((scaleX + scaleY) / 2)
context.beginPath()
context.moveTo(
points[0].x * scaleX,
points[0].y * scaleY,
)
for (const point of points.slice(1)) {
context.lineTo(
point.x * scaleX,
point.y * scaleY,
)
}
context.stroke()
}
}
There is a subtle issue here: clipping the mosaic to the exact shape of the mask can cut through individual pixel blocks. That produces half-pixelated blocks around the edge of a selection.
To keep the mosaic visually consistent, I work at the block level instead.
For every mosaic block inside the selection bounds, I check whether the mask touches that block. If it does, I copy the entire block:
for (let blockY = startY; blockY < endY; blockY++) {
for (let blockX = startX; blockX < endX; blockX++) {
const sourceX = blockX * pixelSize
const sourceY = blockY * pixelSize
const destinationX = Math.round(sourceX * scaleX)
const destinationY = Math.round(sourceY * scaleY)
if (!maskTouchesBlock(
maskData,
destinationX,
destinationY,
blockWidth,
blockHeight,
)) {
continue
}
outputContext.drawImage(
mosaicCanvas,
destinationX,
destinationY,
blockWidth,
blockHeight,
destinationX,
destinationY,
blockWidth,
blockHeight,
)
}
}
This means a selection activates complete mosaic blocks instead of cutting through them.
The result looks cleaner, especially around brush strokes and the edges of rectangular selections.
Step 5: Use one painting function everywhere
I did not want separate algorithms for the editor preview and the downloaded file. That would eventually lead to small differences between what the user sees and what they save.
Instead, both paths call the same function:
type PaintOptions = {
mode: "full" | "selection"
pixelSize: number
selections: Selection[]
}
export function paintPixelated(
context: CanvasRenderingContext2D,
source: CanvasImageSource,
sourceWidth: number,
sourceHeight: number,
destinationWidth: number,
destinationHeight: number,
options: PaintOptions,
) {
context.clearRect(
0,
0,
destinationWidth,
destinationHeight,
)
context.drawImage(
source,
0,
0,
sourceWidth,
sourceHeight,
0,
0,
destinationWidth,
destinationHeight,
)
const mosaic = renderMosaic(
source,
sourceWidth,
sourceHeight,
destinationWidth,
destinationHeight,
options.pixelSize,
)
if (options.mode === "full") {
context.drawImage(mosaic, 0, 0)
return
}
const maskedMosaic = renderSelectedBlocks(
mosaic,
options.selections,
sourceWidth,
sourceHeight,
destinationWidth,
destinationHeight,
options.pixelSize,
)
context.drawImage(maskedMosaic, 0, 0)
}
For the editor, destinationWidth and destinationHeight match the responsive preview.
For export, they match the image’s full dimensions.
That distinction matters. Exporting the visible preview would reduce the final image to the size of the editor. Re-rendering at the source dimensions preserves the available resolution.
Step 6: Export without uploading anything
The export process creates a new canvas at the image’s full size and runs the same painter again:
async function exportImage(
source: CanvasImageSource,
width: number,
height: number,
options: PaintOptions,
) {
const canvas = document.createElement("canvas")
canvas.width = width
canvas.height = height
const context = canvas.getContext("2d")
if (!context) {
throw new Error("Could not export this image.")
}
paintPixelated(
context,
source,
width,
height,
width,
height,
options,
)
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, "image/png")
})
if (!blob) {
throw new Error("Could not create the image file.")
}
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = "pixelated-image.png"
link.click()
URL.revokeObjectURL(url)
}
The whole flow stays inside the browser:
Local file
↓
ImageBitmap
↓
Canvas rendering
↓
Blob
↓
Local download
No image data needs to be sent to Next.js or stored on a server.
What I would keep if I rebuilt it
The UI can change. The framework can change. But I would keep these three decisions:
- Store selections in source-image coordinates.
- Use the same painting function for preview and export.
- Keep image processing in the browser when the server adds no real value.
Those choices solved most of the difficult problems before they became UI bugs.
You can try the finished tool at Pixelate Photo. It supports whole-image pixelation, rectangle and brush selections, adjustable pixel size, comparison, undo and redo, and PNG, JPG, or WebP downloads.
If you are building something similar, start with the smallest useful pipeline: load one image, pixelate it on a canvas, and export it. Add selection tools only after that path works from beginning to end.
Top comments (0)