QR code

A working QR encoder, in the browser, with no dependency — Galois field arithmetic, Reed–Solomon error correction and all. It renders as SVG, so it stays sharp at any size and prints at the printer's resolution rather than the screen's.

When to use it

To move something from a screen to a phone, or from paper to a phone: a ticket, a Wi-Fi network, a link to a page someone is standing in front of, a two-factor enrolment.

The test is whether the reader has a second device. A QR code on a phone screen, to be scanned by that same phone, is a puzzle rather than a shortcut — use a link.

The value is the attribute; deck-extras.js renders the modules on load
<span class="qr" data-deck-qr="https://example.com/tickets/9fQ2xR" role="img" aria-label="QR code for your ticket"></span>
<span class="qr" data-deck-qr="https://example.com/tickets/9fQ2xR"
      role="img" aria-label="QR code for your ticket"></span>

An empty data-deck-qr falls back to the element's own text content, which is occasionally handy — the value stays visible and selectable if the script never runs.

Options

  • data-deck-qr — the value to encode. Anything: a URL, a WIFI: string, an otpauth:// URI.
  • data-ecl — error-correction level, L, M, Q or H, defaulting to M. Higher levels survive more damage and hold less data.
  • data-caption — renders a .qr-caption underneath, capped to the code's own width and set to overflow-wrap: anywhere so a long URL breaks rather than stretching the layout.
104px, 160px and 240px — set by --qr-size, which the size classes change
<div class="cluster" style="align-items:flex-start"> <span class="qr qr-sm" data-deck-qr="https://example.com/a" role="img" aria-label="QR code, small"></span> <span class="qr" data-deck-qr="https://example.com/b" data-ecl="Q" data-caption="example.com/b" role="img" aria-label="QR code for example.com/b"></span> <span class="qr qr-lg" data-deck-qr="https://example.com/c" data-ecl="H" role="img" aria-label="QR code, large"></span> </div>
<div class="cluster" style="align-items:flex-start">
  <span class="qr qr-sm" data-deck-qr="https://example.com/a"
        role="img" aria-label="QR code, small"></span>
  <span class="qr" data-deck-qr="https://example.com/b" data-ecl="Q"
        data-caption="example.com/b"
        role="img" aria-label="QR code for example.com/b"></span>
  <span class="qr qr-lg" data-deck-qr="https://example.com/c" data-ecl="H"
        role="img" aria-label="QR code, large"></span>
</div>

Versions 1 to 10 only. The encoder covers the smaller symbol versions, which is roughly 170 alphanumeric characters at level M and considerably fewer at H. Past that it throws, and the component catches it and renders the error message in place of the code — so a value that is too long shows you a sentence rather than failing silently. Shorten it, or link to it.

.qr-logo on the frame and .qr-logo-mark on a child punches a mark out of the centre — a white square at 22% of the code's width, with your logo inside it.

This works because QR codes carry error correction, and it works only within that budget. The source is explicit about the limit: only safe at error correction Q or H. At the default M you are spending redundancy you may need for a crease in the paper or a reflection on a screen.

data-ecl="H" is not optional here
<span class="qr qr-lg qr-logo" data-deck-qr="https://example.com/deck" data-ecl="H" role="img" aria-label="QR code for example.com/deck"> <span class="qr-logo-mark"> <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#deck-mark"></use></svg> </span> </span>
<span class="qr qr-lg qr-logo" data-deck-qr="https://example.com/deck" data-ecl="H"
      role="img" aria-label="QR code for example.com/deck">
  <span class="qr-logo-mark">
    <svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#deck-mark"></use></svg>
  </span>
</span>

Test the result by scanning it, on a real phone, from the size it will actually be printed at. A code that scans on a 240px screen may not scan at 20mm on a receipt.

Why it stays white in dark mode

.qr sets background: var(--qr-bg, #fff) and fills its modules with var(--qr-fg, #000). Both defaults are literal black and white rather than Deck tokens, so the code does not follow light-dark() and does not invert in dark mode. In a dark interface you get a white card.

That is deliberate and it is the right call. A scanner is not reading your theme: it is looking for high contrast between light and dark modules in the orientation the spec describes. Inverted codes are read by some scanners and not others, and low-contrast codes by almost none. The white card is slightly ugly and always works.

The two custom properties are there if you have a real reason — a brand-coloured foreground on white can scan fine if the contrast is high enough. Change the foreground, keep the background light, and test it before shipping.

--qr-quiet is the padding, and it defaults to 12px. That white margin is part of the specification, not decoration: scanners use it to find the symbol's edges. Setting it to zero produces a code that looks right and frequently will not scan.

Classes

Generated from src/24-media.css by tools/docs/extract.mjs.

Classes, generated from the stylesheet source
Class Layer What it does Source
.qr deck.components deck.js renders the modules into this frame. Kept square, with a quiet zone, so it stays scannable at any size. 24-media.css:344
.qr-caption deck.components Sets margin-block-start, font-size, color, text-align, and 2 more. 24-media.css:375
.qr-lg deck.components Sets --qr-size. 24-media.css:357
.qr-logo deck.components A logo punched out of the middle. Only safe at error correction Q or H. 24-media.css:360
.qr-logo-mark deck.components No declarations. 24-media.css:363
.qr-sm deck.components Sets --qr-size. 24-media.css:358

Tokens

Design tokens this component reads
Token Live Declared value
--line light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral)))
--r-sm 7px
--r-xs 4px
--text-xs .75rem
--text-muted light-dark(var(--ink-600), var(--ink-400))
--space-2 .5rem

--qr-size, --qr-quiet, --qr-bg and --qr-fg are component-level custom properties with literal fallbacks, not Deck tokens — which is the whole point of the section above.

Accessibility

  • A QR code is an image and announces as nothing. The rendered SVG is a grid of unlabelled rectangles. Give the frame role="img" and an aria-label saying what scanning it does — every example here does.
  • Never make it the only route. This is the important one. A QR code is unusable to a blind reader, to anyone without a second device, and to anyone reading a printout at a desk. The same destination must be available as a link or as text you can type — data-caption exists partly for this.
  • Scanning needs steady hands and a working camera. Motor impairment, low vision and an old phone all make it harder. It is a convenience, and treating it as a primary interface excludes people quietly.
  • The caption should be readable, not just present. .qr-caption is --text-xs and muted, which is Deck's smallest text. If it carries the fallback URL, consider making it larger — the whole point is that someone can read and type it.
  • Do not encode something the reader cannot verify. A code is opaque until it is scanned, so a reader has no way to know where it goes. Print the destination beside it, which is both an accessibility measure and a phishing one.
  • Size for the distance. A poster code scanned from two metres needs to be much larger than .qr-lg. The usual rule is roughly one tenth of the scanning distance.

Right to left

A QR code has a fixed orientation defined by the specification and does not mirror in any direction — a flipped code does not scan. Nothing in the component is directional and nothing in src/19-logical.css touches it, which is correct.

The caption is ordinary text and follows the document direction, so an Arabic caption reads right to left under a code that has not moved. A URL inside it stays left-to-right on its own, through the bidirectional algorithm rather than through any rule of Deck's.

The code is unchanged; the caption reads right to left
<span dir="rtl" class="qr" data-deck-qr="https://example.com/tickets/9fQ2xR" data-caption="امسح للحصول على تذكرتك" role="img" aria-label="رمز الاستجابة السريعة للتذكرة"></span>
<span dir="rtl" class="qr" data-deck-qr="https://example.com/tickets/9fQ2xR"
      data-caption="امسح للحصول على تذكرتك"
      role="img" aria-label="رمز الاستجابة السريعة للتذكرة"></span>

Reduced motion

Nothing here animates — no transitions, no keyframes, no transforms. The code appears when the script has finished encoding it, which is not motion. There is nothing for prefers-reduced-motion to act on.

Printing

This is the one component in Deck that is designed for print, and it shows. The print block at the end of src/24-media.css reads:

/* A QR code is often the whole point of printing the page */
.qr { border: 0; break-inside: avoid; }

The border goes because on paper the code needs no frame, and break-inside: avoid stops it being split across two pages — a half-printed QR code is not a degraded QR code, it is nothing at all.

The rest is handled on the component itself rather than in a print block: .qr carries print-color-adjust: exact, which is what stops the browser dropping its white background and black modules as "decorative" colour. Without that line the code would print as an empty square on most default settings.

And because it is SVG, it prints at the printer's resolution. A raster QR code scaled up for print acquires soft edges, and soft edges are what a scanner fails on.

Overriding it

/* One code, no layer needed */
<span class="qr" style="--qr-size:180px" data-deck-qr="…"></span>

@layer app.components {
  /* A brand-coloured foreground. Keep the background light and test it. */
  .qr-brand { --qr-fg: oklch(30% .09 264); }

  /* A larger quiet zone for a code that will be printed small */
  .qr-print { --qr-quiet: 20px; }

  /* A readable caption */
  .qr-caption { font-size: var(--text-sm); color: var(--text); }
}

Deck.qr exposes svg(), build() and encode() if you need a code somewhere the attribute cannot reach — a canvas, a download, a value that only exists after a fetch.

When not to use it

  • Not on a phone, for that phone. If the reader is already holding the destination, give them a link.
  • Not as the only way to reach something. See Accessibility — this is the rule that matters most on this page.
  • Not for more than about 170 characters. The encoder stops at version 10 and will tell you so. Encode a short link, not the payload.
  • Not with a logo at the default error-correction level. data-ecl="Q" at minimum, and test it.
  • Not for a secret. A code on screen is readable by anyone in the room and by anyone in a photograph of the room. Enrolment codes should be short-lived.
  • Not without the quiet zone. Setting --qr-quiet: 0 to make it look tidier is the single most reliable way to produce a code that does not scan.