QR Code
Size, quiet zone, error correction level — and always printing the destination as text beside it.
Also called 2D Barcode, Scan Code — in this system all of them are QR Code.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The quiet zone is not padding
Four modules of white on every side is what lets a scanner locate the symbol. Cropping it is the most common cause of a code that will not read — and it still looks correct.
Never invert for dark mode
Most scanners assume dark modules on light. A code is a machine target that happens to be on your page — it keeps its white field in every theme.
A logo costs error correction
Covering the centre means raising the correction level to H, which makes the symbol denser. It is a real trade, not a free bit of branding.
Always beside the destination
The code, the URL as selectable text, and a copy control. Anyone who cannot scan still has a way through.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
https://acme.dev/d/9fJk2LmThree finder patterns, a field of modules, four modules of quiet zone, and the destination printed as text beside it.
- Module≥ 4px on screen
The smallest square. Below about four device pixels a phone camera cannot resolve them reliably, which is what sets the minimum overall size.
- Quiet zone4 modules, every side
Part of the specification, not padding. It is what lets a scanner find the symbol’s edges, and cropping it is invisible in review.
- Finder patterns7 × 7, three corners
How the scanner establishes orientation. They must never be covered, restyled or rounded away.
- FieldWhite, in every theme
The code keeps its own surface. It is a machine target on your page, not a surface that should follow the theme.
- Error correctionM by default, H with a logo
Level M tolerates about 15% damage; H tolerates 30% and makes the symbol denser. Only raise it when something covers the centre.
- Logo area≤ 20% of the centre
With a white gap around it. Past 20% even level H stops recovering reliably.
- The URLSelectable text beside it
The only route for anyone who cannot scan. Without it the code is a dead end for a screen-reader user or a laptop with no camera.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used for |
|---|---|---|
| #0a0b0e | — | Modules — near-black rather than pure black, for print |
| #ffffff | — | The field and quiet zone, in every theme |
| --ds-border-subtle | — | An optional card edge, outside the quiet zone |
| --ds-fg-secondary | — | The destination URL |
| --ds-fg-muted | — | Expiry and helper text |
Spacing
| Token | Value | Used for |
|---|---|---|
| quiet zone | Margin on every side — part of the code |
Radius
| Token | Value | Used for |
|---|---|---|
| --radius-md | Corners of the white field, never of the modules |
Typography
| Token | Value | Used for |
|---|---|---|
| font-mono | — | The URL, so it can be transcribed if it must be |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Label gap | Min width | Max width | When to use |
|---|---|---|---|---|---|
| Minimum | 96px | — | — | — | About 4px per module for a 21-module symbol. Below this, scanning becomes unreliable. |
| Default | 128px | — | — | — | Comfortable on a laptop screen at a normal viewing distance. |
| Feature | 192px+ | — | — | — | A dedicated pairing or hand-off screen where the code is the subject. |
| — | — | 2cm | — | The physical minimum for a phone camera at arm’s length. Larger for anything scanned from further away. | |
| Quiet zone | — | 4 modules | — | — | Non-negotiable. It scales with the module size, not with the pixel size. |
| Logo | — | — | — | 20% of the symbol | With a white gap around it, and error correction raised to H. |
acme.dev/d/9fJk2LmNot a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Module to field contrast must be as close to maximum as possible. This is a machine-vision requirement, and it is stricter than any WCAG ratio.
- The code keeps a white field in dark mode. Contrast against the page is the container’s problem, not the code’s.
- The URL beside it is content and owes 4.5:1.
- In forced-colors mode the code must be exempt — a QR rendered in system colours is not scannable.
Keyboard
| Tab | Reaches the copy and download controls beside the code. The code itself is not focusable. |
| Enter | Copies the destination, which is what a keyboard user actually needs from this component. |
Screen readers
- Announce the destination, not the format: "QR code linking to acme.dev/d/9fJk2Lm".
- The readable URL is the real accessibility feature. Everything else is a convenience for people who can point a camera at a screen.
- If the code expires, say so in text as well as visually — a code that stopped working with no explanation is unexplainable.
Focus & touch
- The code is not interactive and never focusable. The copy and download controls beside it are, and they are what make the component usable without a camera.
- On a phone, do not show the code at all — nobody scans a screen with the device rendering it. Detect the viewport and offer a link or a copy button instead. If the code must appear for a hand-off to a second device, keep it at least 128px so the other camera can resolve the modules.
| Attribute | Applied to | Notes |
|---|---|---|
| role="img" | The SVG | With aria-label naming the destination: "QR code linking to acme.dev/d/9fJk2Lm". "QR code" alone is useless. |
| Visible text | Beside the code | The most important accessibility feature here. A code with no readable destination is a dead end. |
| aria-describedby | The code | Pointing at expiry or instructions, so the constraints are announced with it. |
| aria-live="polite" | A refreshing code | Announce regeneration: "Code refreshed". A silently changing code is disorienting. |
Example usage
1import { QrCode } from '@/ui/Display'23<Row>4 <QrCode value={pairingUrl} size={128} />5 <Stack>6 <span>Open on your phone</span>7 {/* The only route for anyone who cannot scan. Never omit it. */}8 <code>{pairingUrl}</code>9 <CopyButton value={pairingUrl} label="Copy link" />10 </Stack>11</Row>1213// Nobody scans a code with the device displaying it.14const isPhone = useMediaQuery('(max-width: 640px)')15{isPhone ? <Link href={pairingUrl}>Continue</Link> : <QrCode value={pairingUrl} />}1617// Error correction is a trade, not a default. M is right until something18// covers the centre; H makes the symbol denser.19<QrCode20 value={url}21 errorCorrection={logo ? 'H' : 'M'}22 logo={logo} // ≤ 20% of the symbol, with a white gap23/>2425// The code keeps its own white field in every theme — it is a machine target26// on your page, not a surface that follows the theme.27<div className="rounded-lg bg-white p-3">28 <QrCode value={url} />29</div>3031// A refreshing code must announce itself, or it changes silently mid-scan.32<p role="status" aria-live="polite" className="sr-only">Code refreshed</p>Framework-free HTML
<figure class="ds-qr">
<!-- Name the DESTINATION. "QR code" on its own is useless. -->
<svg
role="img"
aria-label="QR code linking to acme.dev/d/9fJk2Lm"
aria-describedby="qr-expiry"
viewBox="0 0 29 29"
shape-rendering="crispEdges"
>
<!-- The quiet zone is part of the code: 4 modules on every side. -->
<rect width="29" height="29" fill="#ffffff" />
<g transform="translate(4 4)">…</g>
</svg>
<figcaption>
<p>Open on your phone</p>
<!-- The real accessibility feature. -->
<code>https://acme.dev/d/9fJk2Lm</code>
<p id="qr-expiry">Expires in 10 minutes</p>
</figcaption>
</figure>CSS
.ds-qr svg {
/* The code keeps its own field in every theme: it is a machine target
that happens to be on your page. */
background: #ffffff;
border-radius: var(--radius-md);
/* Modules are squares. Anti-aliasing softens their edges and costs
scan reliability at small sizes. */
shape-rendering: crispEdges;
/* About 4px per module for a 21-module symbol. */
min-inline-size: 96px;
}
/* On the container, never inside the quiet zone. */
.ds-qr { padding: 8px; background: #fff; border-radius: var(--radius-lg); }
.ds-qr code {
font-family: var(--font-mono);
font-size: 12px;
color: var(--ds-fg-secondary);
word-break: break-all; /* a long URL must stay fully visible */
}
/* A QR rendered in system colours is not scannable. */
@media (forced-colors: active) {
.ds-qr svg { forced-color-adjust: none; }
}
/* Nobody scans a screen with the device rendering it. */
@media (max-width: 640px) {
.ds-qr__code { display: none; }
.ds-qr__link { display: block; }
}
/* Print is where codes are most useful, and printers drop backgrounds by
default. */
@media print {
.ds-qr svg { background: #fff !important; print-color-adjust: exact; }
}Component API
QrCode
| Prop | Type | Default | Description |
|---|---|---|---|
| value* | string | — | The encoded value. Keep it short — a long URL means more modules and a denser, harder-to-scan symbol. |
| size | number | 128 | Pixels. 96 is the practical floor for reliable scanning on screen. |
| errorCorrection | 'L' | 'M' | 'Q' | 'H' | 'M' | H only when a logo covers the centre. Higher levels make the symbol denser. |
| quietZone | number | 4 | Modules of margin. Reducing it is the most common cause of a code that will not read. |
| logo | ReactNode | — | Centred, at most 20% of the symbol, with a white gap. Requires level H. |
| label | string | — | The accessible name. Defaults to "QR code linking to {value}". |
Professional tips
- Keep the encoded URL short. Every extra character adds modules, and a denser symbol is harder to scan at the same physical size — use a short link, not the canonical one.
- Show an expiry when the code is a session or a pairing token, and refresh it before it lapses rather than after.
- Offer a download for print, and export SVG rather than PNG so it stays sharp at any physical size.
- Test on real hardware, at arm’s length, in poor light. A code that scans in a design review at 20cm may fail on a wall at two metres.
- For Wi-Fi credentials, use the WIFI: URI scheme rather than a URL — the phone joins the network directly instead of opening a browser.
Performance
- Generate the SVG on the client and cache it by value. Fetching a code from a rendering service is a network round trip for something computable locally.
- Prefer SVG over canvas: it scales, it prints sharply, and it is smaller than a PNG at any useful size.
- Only regenerate when the value changes. A code that re-renders on every parent update flickers at exactly the moment someone is aiming a camera at it.
- Do not animate a QR code. Any motion during a scan is a failed scan.
Common mistakes
- Cropping the quiet zone, which breaks scanning while looking correct.
- Inverting for dark mode, which fails on a large share of scanners.
- Tinting the modules, trading a real function for decoration.
- A logo larger than 20% of the symbol, past what even level H can recover.
- No readable URL, leaving anyone without a camera with a dead end.
- Showing a code on a phone, where nobody can scan it.
- aria-label of "QR code" with no destination.
- Encoding a very long URL, producing a symbol too dense to scan at the size shown.
Real-world recommendations
- Codes work best in genuine hand-off moments: desktop to phone, screen to scanner, print to camera. Outside those, a link is almost always better.
- The most valuable part of the component is the readable URL beside it. It handles every case where scanning is impossible, and it costs one line.
- Two-factor enrolment is the archetype: a long secret, a device that cannot type it, and a scanner already open. That is where the pattern earns its place.
- Print is where quiet zones die. Layout tools crop them, and the failure only shows up after the run — check the artwork, not the screen.