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.
registry/behivetech/atoms/qr-code at v0.1.0, snapshotted 2026-11-09
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" />;