QR Code
v0.1.0DraftA QR code drawn as inline SVG for any value: one white plate and one path of black modules, rendered synchronously so it works in server components and prints crisply at any size.
registry/behivetech/atoms/qr-code
qr-code.tsx
1import { type SVGAttributes } from "react";2import { getClassName } from "@bht-component-registry/class-names";3import styles from "./qr-code.module.scss";4import {5 buildQrGeometry,6 type QrErrorCorrection,7 type QrGeometry,8} from "./qr-path.js";910export type { QrErrorCorrection } from "./qr-path.js";1112export interface QrCodeProps extends Omit<13 SVGAttributes<SVGSVGElement>,14 "children" | "role" | "aria-label" | "width" | "height" | "viewBox"15> {16 /** The text the code encodes, usually a full URL. An empty or too-long value renders the invalid box instead. */17 value: string;18 /**19 * The image's accessible name, read by screen readers. Required and never20 * defaulted, so it goes through your i18n layer; the address the code21 * encodes is a good name.22 */23 label: string;24 /** Rendered width and height in rem. Never below 6 (1 in), the smallest size that scans reliably. Defaults to `10`. */25 size?: number;26 /** Error correction: `L` 7%, `M` 15%, `Q` 25%, `H` 30% of the code can be damaged. Defaults to `M`. */27 errorCorrection?: QrErrorCorrection;28 /** Quiet zone in modules, drawn in the white plate. Rounded down to a whole number and never below 0. Scanners need 4; `0` is for cases where the surroundings already leave the space. Defaults to `4`. */29 margin?: number;30 /** Additional class names to merge with the component root element */31 className?: string;32}3334/** One inch at the default root size; a code smaller than this fails on many phones. */35export const QR_CODE_MIN_SIZE = 6;36const DEFAULT_SIZE = 10;37const DEFAULT_MARGIN = 4;38const INVALID_VIEWBOX = 8;39const INVALID_INSET = 0.5;4041/**42 * A QR code drawn as inline SVG: one white plate and one path of black43 * modules, rendered synchronously so it works on the server. The colours are44 * fixed on purpose; inverted or tinted codes fail on many scanners, so the45 * code is black on white in Light, Dark and print. If the value cannot be46 * encoded, a dashed box of the same size takes its place.47 */48export const QrCode = ({49 value,50 label,51 size = DEFAULT_SIZE,52 errorCorrection = "M",53 margin = DEFAULT_MARGIN,54 className,55 ...rest56}: QrCodeProps) => {57 // Clamped once, so the path offset and the viewBox always agree.58 const quietZone = Math.max(Math.trunc(margin) || 0, 0);59 let geometry: QrGeometry | null = null;60 try {61 geometry = buildQrGeometry(value, errorCorrection, quietZone);62 } catch {63 // An unencodable value shows the invalid box; throwing would break the whole server render.64 geometry = null;65 }6667 const [rootClass, getChildClass] = getClassName({68 className,69 rootClass: "BHT__QrCode",70 modifiers: { invalid: geometry === null },71 styles,72 });73 const sideLength = Math.max(size, QR_CODE_MIN_SIZE);74 const side = geometry75 ? geometry.moduleCount + quietZone * 276 : INVALID_VIEWBOX;7778 return (79 <svg80 {...rest}81 className={rootClass}82 role="img"83 aria-label={label}84 width={`${sideLength}rem`}85 height={`${sideLength}rem`}86 viewBox={`0 0 ${side} ${side}`}87 shapeRendering="crispEdges"88 >89 {geometry ? (90 <>91 <rect92 className={getChildClass("plate")}93 fill="white"94 width={side}95 height={side}96 />97 <path98 className={getChildClass("modules")}99 fill="black"100 d={geometry.path}101 />102 </>103 ) : (104 <rect105 className={getChildClass("error")}106 x={INVALID_INSET}107 y={INVALID_INSET}108 width={INVALID_VIEWBOX - INVALID_INSET * 2}109 height={INVALID_VIEWBOX - INVALID_INSET * 2}110 />111 )}112 </svg>113 );114};
qr-path.ts
1import { create } from "qrcode";23/** The error correction levels the encoder accepts, weakest to strongest. */4// Declared here, not aliased from the encoder: qrcode ships no types, so an alias would leak an unresolvable import into the published declarations.5export type QrErrorCorrection = "L" | "M" | "Q" | "H";67/** What the SVG needs to draw one code: the grid size and a single path of dark modules. */8export interface QrGeometry {9 /** Modules per side, quiet zone excluded */10 moduleCount: number;11 /** SVG path data; every dark run is one rectangle in module units */12 path: string;13}1415/**16 * Encodes `value` and turns the module matrix into path data. Adjacent dark17 * modules in a row merge into one rectangle, which keeps the markup small18 * enough to server-render for a sheet of cards.19 *20 * @param value - The text to encode21 * @param errorCorrection - Redundancy level; higher survives more damage22 * @param margin - Quiet-zone width in modules, added around the grid23 * @returns The grid size and the path, or throws when the value cannot be encoded24 */25export const buildQrGeometry = (26 value: string,27 errorCorrection: QrErrorCorrection,28 margin: number,29): QrGeometry => {30 const { modules } = create(value, { errorCorrectionLevel: errorCorrection });31 const { size } = modules;32 const rects: string[] = [];3334 for (let row = 0; row < size; row += 1) {35 let runStart = -1;36 for (let col = 0; col <= size; col += 1) {37 const isDark = col < size && Boolean(modules.get(row, col));38 if (isDark && runStart < 0) {39 runStart = col;40 } else if (!isDark && runStart >= 0) {41 const width = col - runStart;42 rects.push(43 `M${runStart + margin} ${row + margin}h${width}v1h-${width}z`,44 );45 runStart = -1;46 }47 }48 }4950 return { moduleCount: size, path: rects.join("") };51};5253/**54 * Whether `value` fits in a code at the given error correction. Lets a55 * consumer such as a card hide the printed address when the code itself shows56 * the invalid box.57 *58 * @param value - The text to encode59 * @param errorCorrection - Redundancy level, `M` by default as in QrCode60 * @returns `true` when the value can be encoded61 */62export const canEncodeQr = (63 value: string,64 errorCorrection: QrErrorCorrection = "M",65): boolean => {66 let isEncodable = true;67 try {68 create(value, { errorCorrectionLevel: errorCorrection });69 } catch {70 isEncodable = false;71 }72 return isEncodable;73};
qr-code.module.scss
1// Styles read the Material Design 3 custom properties (--md-sys-*) the docs2// site defines. The code itself is the one exception: black modules on a white3// plate in every theme, because inverted or tinted codes fail on many scanners.4// The token sheet has no theme-fixed black or white yet, so the plate and5// modules take their fill from SVG attributes in qr-code.tsx (the CSS keywords6// white and black); swap them for the tokens when the sheet gains them.7.BHT__QrCode {8 display: block;9 flex-shrink: 0;10 max-width: 100%;11 height: auto;12 // Keeps a forced-colours theme from remapping the code.13 forced-color-adjust: none;1415 // The invalid box keeps the code's size so a bad row never collapses a layout.16 &__error {17 fill: none;18 stroke: var(--md-sys-color-error);19 stroke-width: 1px;20 stroke-dasharray: 4 4;21 vector-effect: non-scaling-stroke;22 }23}
index.ts
1export { QrCode } from "./qr-code.js";2export type { QrCodeProps } from "./qr-code.js";3export { canEncodeQr } from "./qr-path.js";
qr-code.example.tsx
1import { QrCode } from "./qr-code.js";23const VALUE = "https://the-behive.com/q/K7M2QX9P";4const LABEL = "the-behive.com/q/K7M2QX9P";56/** A code at the default size. It is black on white in every theme so it always scans. */7export const BasicQrCode = () => <QrCode value={VALUE} label={LABEL} />;89/** The smallest size, 6rem (1 in), for tight spots. Below this the code is raised to 6rem. */10export const SmallQrCode = () => (11 <QrCode value={VALUE} label={LABEL} size={6} />12);1314/** Higher error correction survives scuffs and stickers, at the cost of a denser code. */15export const HighErrorCorrectionQrCode = () => (16 <QrCode value={VALUE} label={LABEL} size={15} errorCorrection="H" />17);1819/** An empty value cannot be encoded, so a dashed box of the same size stands in and keeps its label. */20export const InvalidQrCode = () => <QrCode value="" label="Code unavailable" />;
qr-code.spec.tsx
1import { renderToString } from "react-dom/server";2import jsQR from "jsqr";3import { describe, expect, it } from "vitest";4import { render, screen } from "@testing-library/react";5import { QR_CODE_MIN_SIZE, QrCode } from "./qr-code.js";67const VALUE = "https://the-behive.com/q/K7M2QX9P";8const LABEL = "the-behive.com/q/K7M2QX9P";9const PIXELS_PER_MODULE = 6;10const RUN_PATTERN = /M(\d+) (\d+)h(\d+)v1h-\d+z/g;1112/** Draws the rendered SVG's plate and dark runs into an RGBA buffer, as a browser would at 6 px per module. */13const rasterise = (markup: string) => {14 const host = document.createElement("div");15 host.innerHTML = markup;16 const svg = host.querySelector("svg") as SVGSVGElement;17 const side = Number(svg.getAttribute("viewBox")?.split(" ")[2]);18 const width = side * PIXELS_PER_MODULE;19 const data = new Uint8ClampedArray(width * width * 4).fill(255);20 const path = host.querySelector("path")?.getAttribute("d") ?? "";2122 for (const [, x, y, run] of path.matchAll(RUN_PATTERN)) {23 for (let py = 0; py < PIXELS_PER_MODULE; py += 1) {24 for (let px = 0; px < Number(run) * PIXELS_PER_MODULE; px += 1) {25 const offset =26 ((Number(y) * PIXELS_PER_MODULE + py) * width +27 Number(x) * PIXELS_PER_MODULE +28 px) *29 4;30 data.fill(0, offset, offset + 3);31 }32 }33 }34 return { data, width };35};3637describe("QrCode", () => {38 it("decodes server-rendered output to the same value", () => {39 const { data, width } = rasterise(40 renderToString(<QrCode value={VALUE} label={LABEL} />),41 );42 expect(jsQR(data, width, width)?.data).toBe(VALUE);43 });4445 it.each(["L", "M", "Q", "H"] as const)(46 "decodes at error correction %s",47 (level) => {48 const { data, width } = rasterise(49 renderToString(50 <QrCode value={VALUE} label={LABEL} errorCorrection={level} />,51 ),52 );53 expect(jsQR(data, width, width)?.data).toBe(VALUE);54 },55 );5657 it("exposes an image role named by label", () => {58 render(<QrCode value={VALUE} label={LABEL} />);59 expect(screen.getByRole("img", { name: LABEL })).toBeTruthy();60 });6162 it("draws one white plate before one path of black modules", () => {63 const { container } = render(<QrCode value={VALUE} label={LABEL} />);64 const [plate, modules] = Array.from(65 container.querySelector("svg")!.children,66 );67 expect(container.querySelectorAll("rect")).toHaveLength(1);68 expect(container.querySelectorAll("path")).toHaveLength(1);69 expect(plate.tagName).toBe("rect");70 expect(plate.getAttribute("fill")).toBe("white");71 expect(modules.tagName).toBe("path");72 expect(modules.getAttribute("fill")).toBe("black");73 });7475 it("keeps the code inside the viewBox when the margin is negative", () => {76 const { container } = render(77 <QrCode value={VALUE} label={LABEL} margin={-2} />,78 );79 const zero = render(<QrCode value={VALUE} label={LABEL} margin={0} />);80 expect(container.querySelector("svg")?.getAttribute("viewBox")).toBe(81 zero.container.querySelector("svg")?.getAttribute("viewBox"),82 );83 expect(container.querySelector("path")?.getAttribute("d")).toBe(84 zero.container.querySelector("path")?.getAttribute("d"),85 );86 });8788 it("rounds a fractional margin down for the path and the viewBox alike", () => {89 const { container } = render(90 <QrCode value={VALUE} label={LABEL} margin={2.7} />,91 );92 const two = render(<QrCode value={VALUE} label={LABEL} margin={2} />);93 expect(container.innerHTML).toBe(two.container.innerHTML);94 });9596 it("sizes the svg in rem", () => {97 render(<QrCode value={VALUE} label={LABEL} size={15} />);98 const svg = screen.getByRole("img");99 expect(svg.getAttribute("width")).toBe("15rem");100 expect(svg.getAttribute("height")).toBe("15rem");101 });102103 it("holds a floor of one inch", () => {104 render(<QrCode value={VALUE} label={LABEL} size={2} />);105 expect(screen.getByRole("img").getAttribute("width")).toBe(106 `${QR_CODE_MIN_SIZE}rem`,107 );108 });109110 it("widens the plate by the margin on every side", () => {111 const wide = render(<QrCode value={VALUE} label={LABEL} margin={4} />);112 const narrow = render(<QrCode value={VALUE} label={LABEL} margin={0} />);113 const side = (view: typeof wide) =>114 Number(115 view.container116 .querySelector("svg")117 ?.getAttribute("viewBox")118 ?.split(" ")[2],119 );120 expect(side(wide) - side(narrow)).toBe(8);121 });122123 it("keeps the name and size when the value cannot be encoded", () => {124 const { container } = render(125 <QrCode value="" label="Empty code" size={8} />,126 );127 const svg = screen.getByRole("img", { name: "Empty code" });128 expect(svg.getAttribute("width")).toBe("8rem");129 expect(container.querySelector("path")).toBeNull();130 expect(container.querySelector("rect")).toBeTruthy();131 });132133 it("merges a custom className onto the root", () => {134 render(<QrCode value={VALUE} label={LABEL} className="custom" />);135 expect(screen.getByRole("img").getAttribute("class")).toContain("custom");136 });137});