Every article about round QR codes answers "do they scan?" the same way: yes, as long as you keep good contrast and test it. That's true, and it doesn't help you much.
I maintain qr-code-shapes, a small dependency-free JavaScript library that renders QR codes as SVG inside a circle, a triangle or a hexagon. It's the renderer behind a free generator I run. I wanted a real answer, so I rendered every combination the library supports and fed each one to two open-source decoders at seven sizes.
Short version: all 208 combinations decoded. Two things got in the way, and neither one was the shape.
First, What a "Round" QR Code Actually Is
There's no such thing as a circular QR code. The standard (ISO/IEC 18004) defines a QR symbol as a square grid of modules, and no phone camera has a code path for anything else.
A "round QR code" is therefore a normal square matrix with artwork around it. Here's the relevant bit of the circle renderer, slightly trimmed:
var qz = 4; // quiet zone, in modules
var contPad = 4; // decorative band beyond the quiet zone
var innerR = (n / 2 + qz + contPad) * m;
// The QR is a square - its corners reach (n/2)·√2 modules from the
// centre. For long payloads that pokes past the circle, so grow it.
var halfDiag = (n / 2) * m * Math.SQRT2;
if (innerR < halfDiag + m * 2) innerR = halfDiag + m * 2;
Then it scans a grid around the matrix and drops decorative dots into the empty space. Each dot is skipped if it lands on the matrix or outside the circle:
// Skip QR matrix area (+ tiny margin)
if (pcx >= safeX0 && pcx <= safeX1 &&
pcy >= safeY0 && pcy <= safeY1) continue;
// Skip outside content circle
if (distC > innerR - m * 0.9) continue;
The real matrix is drawn on top, untouched. The decoration is in the same colour, so to your eye it's one round code. To a decoder it's noise around a square. The triangle and hexagon renderers work the same way, just with different geometry.
One thing I didn't expect when I went back through the code: the decorative dots start about one module from the matrix edge, not the four modules of quiet zone the standard asks for. I'd have bet that this would hurt. It didn't, at least not with the decoders below.
The Test
-
Inputs: 4 shapes × 4 frames (
none,thin,double,organic) × 13 module patterns (dots, hearts, stars, pills, bars…) = 208 SVGs. Payloadhttps://koloqr.com, error correction fixed at level H. -
Raster: each SVG rendered with
sharpto 1000, 600, 400, 250, 180, 140 and 110 px square, both sharp and with a small Gaussian blur to stand in for a phone lens. -
Decoders: ZXing-C++ through
zxing-wasm(the C++ port of ZXing, the open-source decoder behind many scanner apps), and jsQR, the most popular pure-JS decoder.
The core loop is short:
import sharp from 'sharp';
import { readBarcodes } from 'zxing-wasm/reader';
import { renderQR, SHAPES, FRAMES, PATTERNS } from './src/index.js';
for (const shape of SHAPES) for (const frame of FRAMES) for (const pattern of PATTERNS) {
const svg = renderQR({ text: 'https://koloqr.com', shape, frame, pattern });
for (const size of [1000, 600, 400, 250, 180, 140, 110]) {
const { data, info } = await sharp(Buffer.from(svg), { density: 300 })
.resize(size, size, { fit: 'contain', background: '#fff' })
.flatten({ background: '#fff' })
.ensureAlpha().raw()
.toBuffer({ resolveWithObject: true });
const img = new ImageData(new Uint8ClampedArray(data), info.width, info.height);
const [hit] = await readBarcodes(img, { formats: ['QRCode'] });
// record hit?.text === 'https://koloqr.com'
}
}
(In Node you need a three-line ImageData shim. Everything else runs as written.)
Result 1: The Shape Doesn't Break the Code
With ZXing-C++, every one of the 208 combinations decoded at 250 px and above, sharp or blurred. Hearts inside a triangle with a wavy frame decoded. Pills inside a hexagon decoded. The shape doesn't matter, because the decoder never looks at it. It finds the three finder patterns, works out the grid and samples the modules. The artwork is outside the area it reads.
Result 2: The Shape Costs You Width
Things got interesting when the images got small. Same artwork width for every shape, ZXing-C++, sharp images, out of 52 combinations per shape:
| Shape | 250 px | 180 px | 140 px | 110 px |
|---|---|---|---|---|
| Square | 52 | 52 | 52 | 52 |
| Circle | 52 | 52 | 52 | 45 |
| Hexagon | 52 | 48 | 44 | 12 |
| Triangle | 52 | 48 | 25 | 0 |
The order matches how much of the artwork the data matrix actually fills. In this renderer that's roughly 70% of a plain square export, 55–60% of a circle's diameter, 53% of a hexagon's width and only about 38% of a triangle's base. A triangle has to be wide enough to fit a square inside it, so most of its width is decoration.
At 110 px that works out to roughly 2.7 pixels per module for the square, 2.2 for the circle, 2.0 for the hexagon and 1.4 for the triangle. Decoders give up somewhere around 2 px per module, and the table shows exactly that.
The practical rule: if a square code works at a given size, the same code in a circle needs to print about 20% wider, a hexagon about a third wider and a triangle nearly twice as wide to give the decoder the same module size. Shape is a layout cost, not a reliability cost.
Result 3: jsQR Will Tell You Your Code Is Broken When It Isn't
This one could have sent me down a rabbit hole. With jsQR, on a sharp 1000 px render, only 20 of 52 square codes decoded, and the other shapes landed in the same range (19 to 28 of 52). Smaller images did better: at 140 px, 51 of 52 square codes decoded.
The failures weren't random. Every pattern made of separate shapes failed: dots, hearts, stars, diamonds, little triangles. Patterns that merge neighbouring modules into solid blocks (square, rounded, the bars) passed. My guess is jsQR's binarizer: at high resolution it sees the white gaps between dots as real light area. Downscaling blurs the dots back into something module-shaped.
ZXing-C++ read all of those at every size from 250 px up. So if you build a QR feature and test it with jsQR on a crisp screenshot, you can "prove" a perfectly good code is broken. Test with more than one decoder, test with a real phone camera, and don't test only at one resolution.
What I'd Tell Anyone Drawing Custom QR Codes
- Never modify the matrix to fit the shape. Draw the square matrix whole and put the shape around it. Error correction (level H recovers roughly 30% of codewords) is a safety margin for logos and damage. Don't spend it on geometry.
- Leave the finder patterns alone. Decoders find them by a 1:1:3:1:1 dark/light ratio through the centre. Rounding their corners is fine. Filling in the light ring, or putting a logo over one, is not.
- Size by modules, not by artwork. Work out the physical size of one module at print size and keep it the same as you'd give a square code.
- Use two decoders and a camera. One open-source decoder is a sample size of one.
Try It
The library is MIT licensed, has no dependencies and gives you an SVG string, so it runs the same in Node, a worker or the browser:
git clone https://github.com/koloqr/qr-code-shapes.git
cd qr-code-shapes
npm test
npm run demo # → http://localhost:8000/demo/
import { renderQR } from './src/index.js';
const svg = renderQR({
text: 'https://example.com',
shape: 'circle',
frame: 'organic',
pattern: 'dots',
color: '#1d4ed8',
});
If you'd rather just make a round code and scan it with your own phone, the generator is preset to the circular shape here: koloqr.com/inspiration/round-qr-codes. It's free, with no signup.
I'd love to see the same test on Apple's Vision and Google's ML Kit. Both are closed, and probably better than either decoder here. If you run it, post your numbers in the comments.
Top comments (0)