Contrast and inverted codes

The style panel on every generator lets you pick a code colour and a background. Those two values are the only colour decision a QR code has, and the engine makes exactly one measurement of them: the WCAG relative-luminance contrast ratio between the pair. From that number and one further comparison it produces two warnings — one when the pair falls below 4.5:1, and a separate one when the code is inverted, with light modules on a dark background. This guide walks through the formula, runs colour pairs through it, and renders one symbol three ways so the warnings are visible rather than abstract.

Every figure here — each luminance, ratio, verdict and warning string, and the version and module count of the example symbol — is computed at build time by the same functions the generator calls as you type; if the engine changed, the page would change with it or the build would fail. It is one of the guides to the conventions behind the site.

Why a scanner cares about contrast

A QR code has two layers: the payload, which is the text encoded inside, and the symbol, which is the matrix of dark and light modules produced to ISO/IEC 18004. Colour belongs entirely to the symbol layer, and even there it arrives late: the encoder has no colour input at all. It produces a matrix of true-and-false cells, and only when that matrix is drawn does each true cell receive the code colour and everything else, including the quiet zone, the background colour.

A reader has to reverse the drawing. For every module it must decide whether it is looking at a dark cell or a light one — the renderer's own note calls this binarizing the image. The difference between the two colours is the margin that decision has. Black on white gives the widest possible margin; a grey on a slightly lighter grey leaves almost none. Whatever happens between the file and the camera — printing, display, lighting — happens after the engine's measurement; it can only measure the two colours in the file.

The formula the style panel uses

The measurement is the contrast ratio defined by WCAG 2.x, the Web Content Accessibility Guidelines, which the renderer cites directly. It runs in three steps.

  1. Parse the colour. Each colour must be a hex value, #rgb or #rrggbb, and is split into red, green and blue channels. Anything else is rejected with a per-field error rather than guessed at.
  2. Compute relative luminance. Each channel is scaled to the range zero to one and passed through the sRGB transfer curve, so that doubling a channel's number does not simply double its light. The three results are then weighted — green most heavily, red next, blue least — and summed. The result runs from 0.000 for black to 1.000 for white.
  3. Divide. The ratio is the lighter luminance over the darker, each with a small constant added so the division stays finite at pure black. It runs from 1.00:1 for two identical colours to 21.00:1 for black on white.

Because the third step always divides lighter by darker, the ratio cannot tell black-on-white from white-on-black; both score 21.00:1. That symmetry is why the inverted check below is a separate test.

Colour pairs through the formula

The table runs 10 pairs through the same assessContrast call the generator makes. The hex values are the inputs; every other column is computed. "Code" is the colour of the dark modules; "L" is relative luminance.

Pair Code Background L (code) L (background) Ratio Result
Black on white (the default) #000000 #ffffff 0.000 1.000 21.00:1 No warning
Dark navy on off-white #1a1a2e #f5f5f5 0.012 0.913 15.65:1 No warning
Dark green on white #1b5e20 #ffffff 0.083 1.000 7.87:1 No warning
Mid grey on white #767676 #ffffff 0.181 1.000 4.54:1 No warning
One step lighter grey on white #777777 #ffffff 0.184 1.000 4.48:1 Low contrast
Pure red on white #ff0000 #ffffff 0.213 1.000 4.00:1 Low contrast
Yellow on white #ffff00 #ffffff 0.928 1.000 1.07:1 Low contrast
White on black #ffffff #000000 1.000 0.000 21.00:1 Inverted
Off-white on dark navy #f5f5f5 #1a1a2e 0.913 0.012 15.65:1 Inverted
Mid grey on dark grey #999999 #333333 0.319 0.033 4.43:1 Low contrast and inverted

Four rows are worth reading twice.

  • The line is a line, not a cliff. #767676 and #777777 differ by one step per channel, and they land on opposite sides of 4.5:1 — 4.54:1 against 4.48:1. The engine does not claim that the first scans and the second does not; it says only that below the line it will speak up. The line marks where the engine starts to warn, nothing more.
  • Vivid is not the same as dark. Pure red on white computes to 4.00:1 and yellow on white to 1.07:1. Both are flagged, because the formula measures luminance, not hue. Dark green on white, a far less vivid pair, draws no warning at 7.87:1.
  • Swapping the colours keeps the ratio and flips the verdict. Dark navy on off-white and off-white on dark navy are the same two hex values in opposite roles. Both compute to 15.65:1; the first is not flagged and the second is inverted.
  • Both warnings can fire at once. Mid grey on dark grey is light-on-dark and sits at 4.43:1, so it collects both messages.

The 4.5:1 line, and what the warning does

4.5:1 is the WCAG 2.x AA threshold for normal text. The renderer adopts it as its warning line because the scanner's dark-or-light decision needs a margin, and that is the one the guidelines settled on for text. When a pair falls below it the engine emits the warning in full, with the computed ratio inside the sentence. For the lighter grey in the table it reads:

Low contrast (4.48:1). Scanners may fail below 4.5:1 — darken the foreground or lighten the background.

The warning is advisory. The code still renders and still downloads with the colours you chose, because the engine validates format, not truth. A low-contrast pair is a perfectly valid symbol in the same way that a mistyped WiFi password is a perfectly valid WIFI: string: the builder cannot know that the password is wrong, and the renderer cannot know that your grey will be printed on grey card.

In the generator the warnings sit beneath the preview, under the status line that reports version, module count, error-correction level and character count, and they update as you move either colour.

Inverted codes: why maximum contrast is not enough

After the ratio, the engine makes a second comparison that has nothing to do with the threshold: is the relative luminance of the code colour greater than that of the background? If it is, the code is inverted, and the engine warns regardless of the ratio. White on black scores 21.00:1, the highest value the formula can produce, and is still flagged with:

Light-on-dark (inverted) codes are not reliably read by all scanners. Prefer dark modules on a light background.

The methodology page states the reason in one clause: the renderer warns separately for light-on-dark (inverted) codes, which many scanners reject. Nothing stronger is claimed here: the site keeps no list of which readers accept inverted symbols; it computes the comparison and tells you which side of it your colours fall.

"Inverted" is decided by luminance, not by the words black and white. A pale yellow code (#fff59d) on a dark navy background (#1a1a2e) computes to 15.27:1 and the engine's verdict is "Inverted"; the same two values swapped, a navy code on pale yellow, compute to 15.27:1 and the verdict is "No warning". The off-white and navy pair in the table shows the same rule: same hex values, same ratio, opposite verdicts. The generator's preview keeps a light surface even in the site's dark theme for the same reason, with the note that codes render on light backgrounds for reliable scanning.

One consequence is easy to miss. The renderer paints the whole canvas — the symbol and the 4-module quiet zone on each side of it — in the background colour first, and only then draws the dark modules in the code colour. The familiar white border is white only because the default background is: choose an inverted pair and the quiet zone goes dark with it. The quiet zone is always rendered and cannot be disabled, and cropping it is the most common self-inflicted scan failure; the quiet zone and print size guide covers it on its own.

The same symbol, three colourings

The payload, built by the URL builder from a fictional menu address, is https://example.com/garden-menu. The encoder turned it into a version 3 symbol, 29×29 modules at error correction Q; with the quiet zone the rendered image is 37 modules across. The level requested was M, the generator's default; the encoder reported Q because the reference encoder raises the level whenever doing so does not increase the version. Those facts are the same in all three renders, because colour never reaches the encoder. The ratio and verdict under each image are the engine's own output for that pair.

Default colours. Code #000000 on background #ffffff: ratio 21.00:1, not inverted.

The engine attaches no warning to this pair.

Dark navy on off-white. Code #1a1a2e on background #f5f5f5: ratio 15.65:1, not inverted.

The engine attaches no warning to this pair.

Off-white on dark navy. Code #f5f5f5 on background #1a1a2e: ratio 15.65:1, inverted.

Engine warning: Light-on-dark (inverted) codes are not reliably read by all scanners. Prefer dark modules on a light background.

What the measurement does not cover

The contrast check is a measurement of two hex values in a file, and it is worth being precise about what that leaves out.

  • Everything after download. How the colours survive printing, how a screen displays them, how much light falls on them and what the camera makes of them are outside the measurement. The methodology page puts it plainly: error correction helps a damaged code scan, but print size and contrast still matter, and a freshly generated code is untested until a real device has scanned it.
  • Whatever surrounds the file. The engine measured the code against its own background, which travels with the file as the quiet zone; it never saw the artwork the image is later placed on.

The misreadings that follow are predictable.

  • Cropping the quiet zone because it looks like empty space. It is part of the symbol, rendered in the background colour, and the renderer refuses to omit it for a reason.
  • Inverting the colours for style. The ratio looks excellent and the engine still warns, because the ratio is not the whole test.
  • Taking a vivid colour for a dark one. Red and yellow on white in the table above are the cautionary cases.
  • Reading the warning as a format error. A warned symbol is still correctly encoded; the payload is intact and the version unchanged. The warning concerns reading the code, not building it — the same boundary the builders draw when they accept a wrong password without complaint.

The encoding guide explains where the version and module count come from, and error correction and logos explains why a centre logo forces the highest recovery level.

Contrast FAQ

Does a contrast warning stop me from downloading the code?

No. The warning is advisory: the code still renders and downloads with exactly the colours you chose. The engine validates format, not truth — a low-contrast pair produces a valid symbol, and the warning is about reading it, not building it.

Why is white on black flagged when its ratio is 21.00:1?

Because the contrast ratio is symmetric: the formula divides the lighter luminance by the darker one, so white on black scores the same as black on white. The engine therefore makes a second, separate comparison — is the code colour lighter than the background? — and warns when it is, because many scanners reject light-on-dark (inverted) codes.

Where does the 4.5:1 number come from?

4.5:1 is the WCAG 2.x AA contrast threshold for normal text. The renderer adopts it as its warning line because a scanner has to decide, for every module, whether it is dark or light, and the contrast between the two colours is the margin that decision has.

Can I use a coloured background instead of white?

The engine will not warn as long as the code colour stays darker than the background and the computed ratio stays at or above 4.5:1. Note that the 4-module quiet zone is painted in the background colour too, so a coloured background surrounds the symbol as well as filling it.

Does the contrast check account for printing or screens?

No. It measures the two hex values in the file and nothing else. Printing, display, lighting and the camera all happen after that measurement, which is why the methodology page says print size and contrast still matter.

Does changing the colours change the payload or the version?

No. Colour is applied when the module matrix is drawn, after encoding has finished, so the version, module count and error-correction level are identical for every colour pair.

Contrast is one of the symbol facts the generator can measure but cannot guarantee; the quiet zone and print size guide covers the others. Every generator here runs in your browser, and how the encoding is built and verified is on the methodology page.