Behive Tech Component Registry

QR Code

v0.1.0Draft

A 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.

pnpm add @behivetech/atoms.qr-code

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";
9
10export type { QrErrorCorrection } from "./qr-path.js";
11
12export 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 never
20 * defaulted, so it goes through your i18n layer; the address the code
21 * 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}
33
34/** 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;
40
41/**
42 * A QR code drawn as inline SVG: one white plate and one path of black
43 * modules, rendered synchronously so it works on the server. The colours are
44 * fixed on purpose; inverted or tinted codes fail on many scanners, so the
45 * code is black on white in Light, Dark and print. If the value cannot be
46 * 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 ...rest
56}: 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 }
66
67 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 = geometry
75 ? geometry.moduleCount + quietZone * 2
76 : INVALID_VIEWBOX;
77
78 return (
79 <svg
80 {...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 <rect
92 className={getChildClass("plate")}
93 fill="white"
94 width={side}
95 height={side}
96 />
97 <path
98 className={getChildClass("modules")}
99 fill="black"
100 d={geometry.path}
101 />
102 </>
103 ) : (
104 <rect
105 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";
2
3/** 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";
6
7/** 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}
14
15/**
16 * Encodes `value` and turns the module matrix into path data. Adjacent dark
17 * modules in a row merge into one rectangle, which keeps the markup small
18 * enough to server-render for a sheet of cards.
19 *
20 * @param value - The text to encode
21 * @param errorCorrection - Redundancy level; higher survives more damage
22 * @param margin - Quiet-zone width in modules, added around the grid
23 * @returns The grid size and the path, or throws when the value cannot be encoded
24 */
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[] = [];
33
34 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 }
49
50 return { moduleCount: size, path: rects.join("") };
51};
52
53/**
54 * Whether `value` fits in a code at the given error correction. Lets a
55 * consumer such as a card hide the printed address when the code itself shows
56 * the invalid box.
57 *
58 * @param value - The text to encode
59 * @param errorCorrection - Redundancy level, `M` by default as in QrCode
60 * @returns `true` when the value can be encoded
61 */
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 docs
2// site defines. The code itself is the one exception: black modules on a white
3// 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 and
5// modules take their fill from SVG attributes in qr-code.tsx (the CSS keywords
6// 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;
14
15 // 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";
2
3const VALUE = "https://the-behive.com/q/K7M2QX9P";
4const LABEL = "the-behive.com/q/K7M2QX9P";
5
6/** 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} />;
8
9/** 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);
13
14/** 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);
18
19/** 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";
6
7const 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;
11
12/** 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") ?? "";
21
22 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};
36
37describe("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 });
44
45 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 );
56
57 it("exposes an image role named by label", () => {
58 render(<QrCode value={VALUE} label={LABEL} />);
59 expect(screen.getByRole("img", { name: LABEL })).toBeTruthy();
60 });
61
62 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 });
74
75 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 });
87
88 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 });
95
96 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 });
102
103 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 });
109
110 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.container
116 .querySelector("svg")
117 ?.getAttribute("viewBox")
118 ?.split(" ")[2],
119 );
120 expect(side(wide) - side(narrow)).toBe(8);
121 });
122
123 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 });
132
133 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});