Last week we added QR code design to Link in Seconds: colours, dot styles, rounded corners, a logo in the middle and a "Scan me" frame. The tricky part was not drawing pretty QR codes. It was making sure no combination of options could produce a code a phone can't read.
Here is how we did it, including the two styles we threw away because our own tests failed them.
One SVG string for everything
We generate the code once, as an SVG string, in a pure function:
export function qrSvg(text: string, design: QrDesign, sizePx = 1024): string | null
That one string is:
- the live preview (
<img src="data:image/svg+xml,...">) - the SVG download (sharp at any print size)
- the source of the PNG download (drawn onto a
<canvas>in the browser)
No DOM, no canvas, no network inside the function, so it runs the same in the browser and in tests.
We use the qrcode package only for the hard part, the module matrix:
const qr = QRCode.create(value, { errorCorrectionLevel: logo ? "H" : "M" });
const n = qr.modules.size;
for (let y = 0; y < n; y++)
for (let x = 0; x < n; x++)
if (qr.modules.get(y, x)) { /* draw a dot */ }
Then we draw every dark module ourselves as a <rect> or <circle>, which is what makes the styles possible.
Rule 1: the three "eyes" are drawn separately
The big squares in three corners (finder patterns) are how a scanner locates and orients the code. We skip those modules in the dot loop and draw each eye as three solid shapes: an outer ring, a gap and a centre. Styling (square or rounded, any colour) only changes the corner radius and fill. The shape always stays solid.
Rule 2: a logo forces error correction H
QR codes can lose part of their data and still decode. Level H tolerates the most damage (about 30%). When a logo is added we:
- switch to level H automatically,
- cap the logo box at about 22% of the code width, snapped to whole modules,
- clear the modules under it plus a one-module margin, so the logo sits on a clean background.
The user never picks an error correction level. The tool picks the safe one.
Rule 3: warn about contrast
A designer will happily pick pale grey on white, or white on black. Both look fine on screen and fail in real light. We compute the WCAG contrast ratio and require darker dots than background:
export function contrastOk(fg: string, bg: string): boolean {
return luminance(fg) < luminance(bg) && contrastRatio(fg, bg) >= 3;
}
If it fails, the UI shows a warning under the colour pickers. We don't block the download, we just tell you.
The test that keeps us honest
Every style gets rendered to pixels and decoded with a real QR reader (jsQR). If the decoded text isn't the input, the test fails:
async function decode(svg: string): Promise<string | null> {
const img = await loadImage(Buffer.from(svg)); // @napi-rs/canvas
const canvas = createCanvas(img.width, img.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0);
const { data, width, height } = ctx.getImageData(0, 0, img.width, img.height);
return jsQR(new Uint8ClampedArray(data), width, height)?.data ?? null;
}
it.each(designs)("%s scans", async (_name, design) => {
expect(await decode(qrSvg(URL, design, 600)!)).toBe(URL);
});
We run it across short URLs, long URLs with query strings, and a Wi-Fi string, for every style combination: dots, rounded, logo, frame and all of them at once.
What the test caught
This is the part I'm glad we automated.
Circle-shaped corner eyes. They look lovely. They failed to decode. A ring of stroke around a dot doesn't give the scanner the crisp 1:1:3:1:1 ratio it looks for. We dropped the option instead of shipping it.
Dots that were slightly too small. Our first "dots" style used r="0.45" (90% of the module). It decoded for some colour pairs and failed for others. At r="0.5", where neighbouring dots touch, every test passed.
Rounded squares with a gap. We first drew rounded modules at 0.9 width with a gap between them. Combined with a logo, decoding failed on short URLs (smaller codes, fewer modules to spare). Making each rounded module fill its full cell fixed it.
None of these were visible problems. They all looked fine. Without a decode test we would have shipped codes that fail on someone's restaurant menu.
Safety: the input is user-controlled
Because we build an SVG string from user input, we treat everything as untrusted:
-
Colours must match
/^#[0-9a-f]{6}$/ior fall back to the default, sored" onload="..."never reaches the markup. - Frame text is HTML-escaped and capped at 24 characters.
-
The logo must be a
data:image/(png|jpeg|webp|gif|svg+xml);base64,URL. A remotehttps://URL is dropped. - The logo is read with
FileReaderin the browser and never uploaded.
Each of these has a test too.
The result
Free, runs in the browser, PNG and SVG out, and every style it offers has been decoded by a QR reader in CI: linkinseconds.com/qr-code-generator.
If you're building anything that generates QR codes, my one takeaway: add a decode test. It takes 20 lines and it will catch things your eyes won't.
Built with Next.js, TypeScript, the qrcode package, jsQR and @napi-rs/canvas for tests. Questions welcome in the comments.
Top comments (2)
Nice
Good articel. Thanks for snippet