Why the QR tickets wouldn't scan: an SVG with no viewBox

Event tickets drawn as inline SVG QR codes failed to scan because the SVG had a fixed width and height but no viewBox, so CSS resized its box without scaling the drawing and cut off a corner pattern. A viewBox and a four-module white margin fixed it.

On 20 October I was testing the invitation site for a mattress brand's event. Each guest gets a personal link, and once they confirm, the page shows them a QR code. At the door, staff open a check-in page and scan that code with a phone camera. I had just asked for the QR to sit in the middle of the card. Then I tried to scan one myself, and it would not read.

I was working with a coding agent in the terminal that afternoon, and the checks below were run in that session.

Reproducing it without a phone

A phone camera is a poor test instrument, so we took it out of the loop. The check-in page decodes with jsQR 1.4.0, and we used that same file. We opened an invitation at a 390-pixel-wide viewport, took screenshots of the QR in each state, and ran them through jsQR in a browser.

What was on screenjsQR result
RSVP card with its earlier CSSnot readable
RSVP card with the CSS that had gone live a few minutes earlierreadable
Ticket pagenot readable
RSVP card after the fixreadable
Ticket page after the fixreadable

The first three rows came from the same Ruby call and encoded the same URL. One of them scanned. That ruled out the data and pointed at how each page sized the image.

Why it broke

The QR is generated on the server with the rqrcode gem, version 3.2.0:

qr.as_svg(module_size: 4, standalone: true)

With those options rqrcode writes an <svg> with a fixed width and height in pixels and no viewBox. The gem has a viewbox option. It is off by default, and the comment in its source says what it is for: it replaces width and height "with a viewBox, allows CSS scaling".

That comment describes the whole bug. Without a viewBox, one unit inside the SVG equals one CSS pixel (SVG 2, section 8.4). CSS can make the element's box bigger or smaller, but the drawing inside keeps its size. The card's earlier CSS gave the QR a 170-pixel box and set the SVG to 210 pixels wide. The drawing did not scale to fit, and its right side was cut off.

The right side of a QR code holds one of the three large squares in its corners. These finder patterns are what let a scanner "orient and determine the perspective of the grid before decoding begins" (Wikipedia, "QR code"). With one of them gone, jsQR returned nothing at all.

The fix

Two more options on the same call:

# before
qr.as_svg(module_size: 4, standalone: true)

# after
qr.as_svg(module_size: 4, offset: 16, viewbox: true, standalone: true)

viewbox: true swaps the fixed size for a viewBox, so CSS scales the whole drawing to whatever box it gets. offset: 16 at 4 pixels per module adds a white margin four modules wide. The offset defaults to 0, so until then the code had no margin of its own. The QR standard asks for exactly that margin, the quiet zone: "QR Code requires a four-module wide margin at all sides of a symbol" (Denso Wave, qrcode.com).

Here is the same mechanism in plain HTML, with three black squares standing in for the finder patterns. Open it in a browser. The left box shows one square, the right box shows all three.

<style>
  .qr { display: inline-block; width: 140px; height: 140px; margin: 8px; outline: 1px solid #999; }
  .qr svg { width: 100%; height: 100%; }
</style>

<!-- Before: fixed width and height, no viewBox.
     CSS shrinks the box to 140px, the drawing stays 196px and is clipped. -->
<div class="qr">
  <svg xmlns="http://www.w3.org/2000/svg" width="196" height="196">
    <rect x="0" y="0" width="28" height="28"/>
    <rect x="168" y="0" width="28" height="28"/>
    <rect x="0" y="168" width="28" height="28"/>
  </svg>
</div>

<!-- After: viewBox instead of width and height, plus a 16px white margin
     (four 4px modules). The whole drawing scales into the box. -->
<div class="qr">
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 228 228">
    <rect width="228" height="228" fill="#fff"/>
    <rect x="16" y="16" width="28" height="28"/>
    <rect x="184" y="16" width="28" height="28"/>
    <rect x="16" y="184" width="28" height="28"/>
  </svg>
</div>

The app had four places that draw a QR. Three of them get their size from CSS: the RSVP card, the ticket page, and the admin page that lists every guest's code. All three got viewbox: true. The card and the ticket also got the margin, which the admin page already had. The fourth place produces the SVG file staff can download. Nothing resizes that file, so it stayed as it was.

The test that passed the whole time

The existing test checked that the RSVP card contained an SVG:

assert_select ".confirmed-qr svg"

That assertion stayed green while the code could not be scanned. We changed it to require the attribute, svg[viewBox], and it failed on the first run with the fix already in place. The HTML parser behind the test had lowercased the attribute name, so the selector that matches is svg[viewbox]. After that the suite passed: 31 tests, 444 assertions.

After the deploy we checked the markup on the live page. The first check matched the wrong element, a small icon on the same page that already had a viewBox. Matching the QR's own <svg> tag showed the new viewBox on both the card and the ticket.

What I'd do differently

Any SVG that CSS is going to size should carry a viewBox from the start. A fixed width and height make sense for a file that is used as it is, like the download, and almost nowhere else.

The test should prove the code scans. The screenshot-and-jsQR check we ran by hand could live in the test suite. An assertion about one attribute catches this exact bug and nothing else.

Check every page where the element appears. The same markup read fine on the card and failed on the ticket, so testing only the card would have said everything was fine.

When verifying production, match the exact element you changed. A search for "viewBox" will find the first icon on the page and report success.