You've seen the watermark. It's not the press-credit bar at the bottom of news photos — that's a different thing. I mean the preview watermark stock photo sites use on unpurchased images: "Getty Images" or "Shutterstock" repeated diagonally across the entire photo, faded to maybe 40% opacity, rotated at roughly -30 degrees, impossible to crop out.
That diagonal tile pattern is one of the most effective anti-piracy designs ever shipped on the web. You can't crop it out (covers the whole image). You can't clone-stamp it out (the text is everywhere, no clean background to sample). You can barely even read what's underneath, but you can read it enough to decide whether to buy.
A friend of mine needed to add this exact style to a batch of unreleased product photos he was sending to a client for review (the client had stolen design concepts before — he wanted "watermarked preview" versions until payment cleared). I'd never built one. So I spent a Saturday reverse-engineering it.
This post is the technical writeup. By the end you'll have working Canvas code that produces a near-perfect Getty/Shutterstock-style diagonal tile watermark on any image, including the spacing bug that wrecked my first version.
If you just want the tool without the code: we built one here. Drag in any photo, type your watermark text or upload a logo, get the watermarked image back. Runs entirely in your browser — files never upload. Otherwise, keep reading.
What makes the diagonal tile watermark anti-piracy
Before writing any code, it's worth understanding why this design is the standard. Other watermark styles fail for specific reasons:
- Corner watermark — crop it off and the photo is clean.
- Bottom bar — same problem, plus easy to clone-stamp.
- Single centered watermark — opaque enough to be useful blocks the photo, transparent enough to see through is easy to remove with content-aware fill.
- Diagonal tile — covers the entire image with repeating text, so cropping leaves more text behind, and content-aware fill has nothing clean to sample from. The text is everywhere.
That's why Getty, Shutterstock, Adobe Stock, iStock, Alamy, and every other stock site converged on the same pattern. It's not aesthetic — it's defensive.
The four design rules
Studying real Getty and Shutterstock previews, four rules make the watermark look authentic:
- Rotation between -25° and -45°. Getty uses about -30°, Shutterstock about -45°. Horizontal text reads too cleanly and is easier to remove. Vertical is awkward. Diagonal forces the eye to fight the tilt.
- Opacity around 35-45%. Below 30%, content-aware fill removes it. Above 50%, you can't read the photo. The sweet spot is enough to mark but not enough to obstruct.
- Tile spacing roughly equal to text width + a small gap. Too tight and the text overlaps itself. Too loose and there are obvious gaps to crop into.
- Color: white or near-white. Dark watermarks disappear into dark photos and become camouflage. White is universally legible against most backgrounds.
Get any of these wrong and the watermark either fails as protection (too easy to remove) or fails as a preview (too hard to see the photo). The proportions matter more than people realize.
The first naive implementation
Here's the simplest possible version. Loop across the image, draw rotated text at each position, done:
function watermark(imageUrl, text) {
return new Promise((resolve) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.globalAlpha = 0.4;
ctx.font = 'bold 48px Arial';
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Draw text at every grid position, each rotated -30°
const spacing = 200;
for (let y = 0; y < canvas.height; y += spacing) {
for (let x = 0; x < canvas.width; x += spacing) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(-Math.PI / 6); // -30 degrees
ctx.fillText(text, 0, 0);
ctx.restore();
}
}
resolve(canvas.toDataURL('image/jpeg', 0.92));
};
img.src = imageUrl;
});
}
This works, sort of. Run it and you'll immediately see two problems:
- The corners of the image are bare. Rotating each tile individually leaves diagonal seams of empty space along the image edges.
- Tile positions don't line up. Because each tile rotates independently around its own origin, the visual pattern feels chaotic instead of grid-aligned.
The fix is one of those mental flips that's obvious once you see it: don't rotate each tile. Rotate the entire coordinate system once, then draw tiles in straight rows and columns inside the rotated frame.
Rotate the grid, not the tiles
Here's the technique. We translate the canvas origin to the center, rotate by -30°, then draw a regular axis-aligned grid in that rotated coordinate system. From the user's perspective, the grid appears tilted because the coordinate system is tilted.
ctx.save();
ctx.translate(canvasWidth / 2, canvasHeight / 2);
ctx.rotate(-Math.PI / 6);
for (let y = -bound; y <= bound; y += spacing) {
for (let x = -bound; x <= bound; x += spacing) {
ctx.save();
ctx.translate(x, y);
ctx.fillText(text, 0, 0); // no per-tile rotation
ctx.restore();
}
}
ctx.restore();
Two things to notice:
- The loop iterates from -bound to +bound, not 0 to canvas size. Because we translated to the center, coordinates are now centered on (0, 0), so we need to cover both negative and positive directions.
- No per-tile rotation. The tiles are drawn flat in the rotated frame. The canvas does the rotation work for us.
But there's a new problem: what's bound? The image is canvasWidth × canvasHeight, but after rotation by -30°, the corners of the original image sit at coordinates that are further from the center than the image dimensions suggest.
The diagonal calculation
When you rotate a rectangle, the bounding box of the rotated shape is larger than the unrotated rectangle. To make sure tiles cover the entire image even at the rotated corners, you need to tile out to at least half the image's diagonal length.
const diagonal = Math.sqrt(canvasWidth ** 2 + canvasHeight ** 2);
const bound = diagonal / 2;
This is conservative — it covers a circle inscribing the rectangle, which is more than the rotated rectangle actually needs. But it's simpler than computing the exact rotated bounding box, and the extra tiles outside the canvas get clipped automatically when you draw to the canvas. Zero-cost insurance.
The spacing trap
Here's the bug I shipped in version one and only caught in testing: my tool let users adjust the tile spacing with a slider. Default was 150px. Worked great with short text like "Getty" at 48px font.
Then I tested with longer text — "ConvertKr Photography Studio © 2026" at the same font. The tiles overlapped catastrophically. Adjacent tiles drew on top of each other because the rendered text was ~600px wide while spacing was 150px.
The fix is to derive a minimum spacing from the actual rendered watermark dimensions, separately for horizontal and vertical axes:
const metrics = ctx.measureText(text);
const textWidth = metrics.width;
const textHeight = fontSize * 1.2; // approximation — works for most fonts
const spacingX = Math.max(userSpacing, textWidth + 30);
const spacingY = Math.max(userSpacing, textHeight + 15);
Why separate X and Y? Because text is much wider than it is tall. If you use one spacing value for both axes, you either get gaps that are too big vertically (when sized for horizontal) or text that overlaps horizontally (when sized for vertical). Separate values give you tight vertical packing (rows close together) and comfortable horizontal spacing (text doesn't run into itself). That's the actual Getty/Shutterstock look.
The 30 and 15 are gap padding. Tweak to taste — 30 horizontal and 15 vertical produces output that closely matches real stock previews.
The polished version
Putting it all together. Full file as a Gist on GitHub →
function addStockWatermark(imageUrl, options = {}) {
const {
text = 'Sample',
fontSize = 48,
fontFamily = 'Arial',
color = '#ffffff',
opacity = 0.4,
rotation = -30, // degrees
userSpacing = 150, // minimum spacing in px
paddingX = 30,
paddingY = 15,
} = options;
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// Set up font once so measureText returns accurate width
ctx.font = `bold ${fontSize}px ${fontFamily}`;
ctx.fillStyle = color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Compute non-overlapping spacing from actual text dimensions
const textWidth = ctx.measureText(text).width;
const textHeight = fontSize * 1.2;
const spacingX = Math.max(userSpacing, textWidth + paddingX);
const spacingY = Math.max(userSpacing, textHeight + paddingY);
// Rotate the grid, not the tiles
ctx.save();
ctx.globalAlpha = opacity;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
// Tile out to half the diagonal so rotated corners stay covered
const diagonal = Math.sqrt(canvas.width ** 2 + canvas.height ** 2);
const bound = diagonal / 2;
for (let y = -bound; y <= bound; y += spacingY) {
for (let x = -bound; x <= bound; x += spacingX) {
ctx.fillText(text, x, y);
}
}
ctx.restore();
ctx.globalAlpha = 1;
resolve(canvas.toDataURL('image/jpeg', 0.92));
} catch (err) {
reject(err);
}
};
img.onerror = () => reject(new Error('Image failed to load'));
img.src = imageUrl;
});
}
Usage:
const url = await addStockWatermark('/photo.jpg', {
text: 'YOUR BRAND',
opacity: 0.4,
rotation: -30,
});
document.getElementById('preview').src = url;
That's the complete implementation. About 60 lines of JavaScript, no dependencies, runs entirely in the browser.
CORS gotcha
If you're loading images from another domain, crossOrigin = 'anonymous' requires the remote server to send Access-Control-Allow-Origin headers. Most don't. The clean workaround is to have users upload their own files using a file input — local file objects have no CORS restrictions:
const fileInput = document.getElementById('file');
const url = URL.createObjectURL(fileInput.files[0]);
const watermarked = await addStockWatermark(url, { text: 'PREVIEW' });
That's the approach we took for the tool itself. No backend involved — the file never leaves the user's device.
Performance notes
For a 4000×3000 image with default spacing, the tile loop draws roughly 150-200 text instances. Total render time on a mid-range laptop is 30-80ms — most of it is the drawImage() for the source bitmap, not the watermark loop.
The toDataURL() call at the end is the slowest step (200-500ms for a 4K JPEG). If you need to display the result instantly and only save later, swap to canvas.toBlob() which is async:
canvas.toBlob((blob) => {
const url = URL.createObjectURL(blob);
// use url for preview, no parsing cost
}, 'image/jpeg', 0.92);
Image watermarks instead of text
The same pattern works if you want to tile a logo PNG instead of text:
// In place of fillText, use drawImage
for (let y = -bound; y <= bound; y += spacingY) {
for (let x = -bound; x <= bound; x += spacingX) {
ctx.drawImage(logoImage, x - logoW / 2, y - logoH / 2, logoW, logoH);
}
}
One detail: most user-uploaded logos arrive in black or full-color. For a stock-watermark style you usually want them tinted to a single color (typically white). The trick is globalCompositeOperation = 'source-in' on an offscreen canvas:
const off = document.createElement('canvas');
off.width = logoW;
off.height = logoH;
const octx = off.getContext('2d');
octx.drawImage(logoImage, 0, 0, logoW, logoH);
octx.globalCompositeOperation = 'source-in';
octx.fillStyle = '#ffffff';
octx.fillRect(0, 0, logoW, logoH);
// Now `off` is a white silhouette of the logo — use it like wmImage
This recolors every opaque pixel to white while preserving transparency.
A note on legality
You can apply any watermark you want to your own photos. What you should NOT do is generate a "Getty Images" branded watermark on a photo and try to pass it off as being from Getty. That's brand impersonation and Getty's legal team is famously aggressive about it.
Legitimate uses I've seen:
- Watermarking your own unreleased product photos for client preview
- Photography portfolios marking unpurchased proofs
- Educational content explaining how watermarks work
- Memes (half the internet's joke screenshots use a Getty-style overlay for comic effect)
- Your own stock photo business protecting previews on your site
Don't impersonate someone else's brand. Use your own name, business, or copyright notice.
The free tool version
If you don't want to ship 60 lines of Canvas code into your own project, we built this as a free in-browser tool: convertkr.com/getty-images-watermark. Drop in any image, type a watermark or upload a logo, get the result back. Files never upload anywhere — everything runs in your browser.
Built during the Saturday that started this post. My friend uses it for client mockups now. A couple of meme-page admins on Twitter use it too, which I find weirdly satisfying.
What I'd love to see in the comments:
- Have you reverse-engineered other iconic visual patterns? (The Wikipedia infobox, the Spotify gradient, the "as seen on" press bar)
- Cleaner approach to text dimensions than
fontSize * 1.2for height? Anyone done it viaactualBoundingBoxAscent+actualBoundingBoxDescentand found gotchas? - Anyone running tiled watermarks in production at scale? Curious about throughput per node and whether it's CPU or memory bound first.
The code is MIT — fork it on GitHub, ship it, change it. If you build something interesting with it, share back.
Top comments (0)