import * as React from "react"
import { create } from "qrcode"
import { cn } from "@/lib/utils"
/**
* How much of the symbol may be obscured and still read: L ~7%, M ~15%,
* Q ~25%, H ~30%. More correction means a denser symbol for the same payload.
*/
export type QrCodeLevel = "L" | "M" | "Q" | "H"
/**
* The four light modules a scanner needs around the symbol. Not a prop: the
* spec's minimum is the only value that is ever right, and a symbol printed
* without it is one nothing can read.
*/
const QUIET_ZONE = 4
/**
* The plate and the ink, as literals — the one documented exception to the
* tokens rule. ISO 18004 defines a symbol as dark modules on a light ground,
* and a scanner that does not try inversion (ZXing's default, several
* authenticator apps) reads nothing else. Drawn in `currentColor` on the page,
* the dark theme inverted the symbol — white on near-black — and the
* two-factor page could not be enrolled from. So the symbol is painted the
* same in every theme: black on a white plate that fills the viewBox, quiet
* zone included. The plate is not the page's paper on purpose.
*/
const PLATE = "#fff"
const INK = "#000"
/**
* The symbol for `value` as rows of booleans, dark first.
*
* An empty matrix when there is nothing to encode, or when the payload is past
* the format's capacity (~2,950 bytes at level L) — those are the two ways
* `create` refuses, and neither should take a page down with it.
*/
export function qrModules(value: string, level: QrCodeLevel = "M"): boolean[][] {
if (!value) return []
let symbol
try {
symbol = create(value, { errorCorrectionLevel: level })
} catch {
return []
}
const { size, data } = symbol.modules
return Array.from({ length: size }, (_, row) =>
Array.from({ length: size }, (_, col) => data[row * size + col] === 1)
)
}
/**
* One `d` for the whole symbol: each horizontal run of dark modules as a
* one-module-tall rectangle, offset by the quiet zone.
*
* Runs rather than a rectangle per module, because a 45x45 symbol is 2,025
* squares and half of them are neighbours — and neighbouring rects seam under
* a fractional scale factor where one path does not.
*/
function qrPath(modules: boolean[][]): string {
const parts: string[] = []
for (let row = 0; row < modules.length; row++) {
let start = -1
// One past the end, so a run reaching the right edge is closed by the same
// branch that closes every other run.
for (let col = 0; col <= modules.length; col++) {
const dark = col < modules.length && modules[row][col]
if (dark && start === -1) start = col
if (!dark && start !== -1) {
const width = col - start
parts.push(`M${start + QUIET_ZONE} ${row + QUIET_ZONE}h${width}v1h-${width}z`)
start = -1
}
}
}
return parts.join("")
}
export type QrCodeProps = React.ComponentProps<"svg"> & {
/** What the symbol encodes — a URL, an OTP auth string, a Wi-Fi credential. */
value: string
/** Rendered edge in CSS pixels; the symbol scales to it. */
size?: number
level?: QrCodeLevel
/** The image's accessible name; the value itself when there is none. */
label?: string
}
/**
* A QR symbol as an SVG: one path of black modules on a white plate, the same
* in every theme.
*
* The matrix comes from `qrcode`'s `create`, and the drawing is this
* component's own — no canvas, no image. The plate carries the spec's
* four-module quiet zone inside the viewBox, so a scanner reads the symbol
* whatever the page behind it is painted with, dark mode included.
*/
function QrCode({
className,
value,
size = 160,
level = "M",
label,
...props
}: QrCodeProps) {
const modules = qrModules(value, level)
const side = modules.length + QUIET_ZONE * 2
return (
<svg
data-slot="qr-code"
data-level={level}
data-empty={modules.length === 0 || undefined}
role="img"
// An empty value with no label would otherwise leave the image nameless
// (an empty aria-label does not count as a name).
aria-label={label || value || "QR code"}
width={size}
height={size}
viewBox={`0 0 ${side} ${side}`}
// Modules land on whole pixels only at some sizes; without this the
// browser antialiases every edge and a scanner sees a grey symbol.
shapeRendering="crispEdges"
xmlns="http://www.w3.org/2000/svg"
className={cn("shrink-0", className)}
{...props}
>
<rect x={0} y={0} width={side} height={side} fill={PLATE} />
{modules.length > 0 ? <path d={qrPath(modules)} fill={INK} /> : null}
</svg>
)
}
export { QrCode }