QR Code
component / data
A QR code as inline SVG from a hand-rolled encoder — no npm dependency, no canvas, no client boundary — drawn as squares, dots or fluid pills, with a centre slot the item sizes so it still scans, a once-only bloom, and an opt-in pointer tilt with a scanning beam.
- type
- component
- files
- 4
- registry deps
- skeleton
- npm deps
- none
installs
- lib/automindz/qr.ts
- components/automindz/qr-code.tsx
- components/automindz/qr-code-scan.tsx
- styles/automindz-qr.css
- Hand-rolled encoder, no npm dependency, no canvas, no client boundary
- square, dot or fluid pills over one fixed matrix — eyes and alignment drawn solid so it still locates
- A centre mark forces level H and clears a hole the item sizes
- reveal blooms once from the centre; QrCodeScan tilts and sweeps on hover — both opt-in, one stylesheet
- Version 10 is the ceiling; past it, the reason renders in place
<QrCode value="https://…" label="Pay invoice 123" /> — that is the whole install: no stylesheet, no config file, no "use client". The encoder is lib/automindz/qr.ts, plain arithmetic (byte mode, versions 1–10, all four error-correction levels, Reed–Solomon, the eight masks scored by the spec's penalty rules), so the code is in the HTML on first paint, prints sharp at any size and needs no size prop — **give the container a width and the code is a square of it**. label is required and is the accessible content: it should say what scanning *does* ("Add this account to your authenticator"), not "QR code", which the role="img" already says. The modules are currentColor and the ground is the container's, so text-foreground on bg-background or bg-card scans with nothing configured; a dark theme inverting both together is fine, a mid-grey on a muted ground is not — a scanner wants contrast, not a palette. margin (default 4 modules, the spec's quiet zone) is drawn as empty space inside the SVG's box; set it to 0 only when the frame around the code already supplies at least four modules of the same colour. **Version 10 is the ceiling** — 213 UTF-8 bytes at the default level M (271 at L, 151 at Q, 119 at H) — because past it the code is denser than a phone reads at a card's width, so a longer payload is a shorter URL and not one more table row; past the ceiling the component renders the reason in place as a role="alert" in the same square footprint, never a code that looks fine and does not scan, and tryEncodeQr gives you the same result as data. **The matrix is fixed; the ink over it is yours.** shape="square" | "dot" | "fluid" — fluid merges runs of modules into pills and fills the notch where two runs meet, dot is a disc 0.84 of a cell — and the finder eyes and alignment patterns are drawn solid in the same idiom, because a decoder *locates* by them and a field of loose dots gives it nothing to find. Every shape scans: a decoder samples the centre of each module and every one of these covers it. **Pass children for a mark in the centre** and three things happen as one decision: level H is forced, a centred square of qrHoleSide(size) modules (7.8% of the code) is cleared, and the mark is laid over it with a module of breathing room, aria-hidden. Keep the mark's ground light — a dark tile filling the hole is a block of false modules. **reveal blooms the code once from the centre**: rings by distance, each start derived from QR_BLOOM_SECONDS / rings, so every version takes the same time and the eyes land last; nothing is removed from the document, so the finished code is what reduced motion, print, a headless render and a missing stylesheet all get, and it never replays. **<QrCodeScan>** (components/automindz/qr-code-scan.tsx, the item's only client boundary) leans the code toward the pointer and sweeps a beam while hovered — presentation only, the code inside is untouched. reveal and QrCodeScan need import "@/styles/automindz-qr.css" once in your root layout; a plain <QrCode> never does. Dials: --automindz-qr-bloom, --automindz-qr-ring, --automindz-qr-sweep, --automindz-qr-beam. ecLevel trades capacity for damage tolerance (L 7%, M 15%, Q 25%, H 30%); M is what authenticators and payment providers use, H is for print or a mark laid over the centre. An empty value renders nothing; loading is a square skeleton at the code's size. Encoding is about a millisecond for a version 10 code, once per render — nothing in a server component, worth a useMemo in a client tree that re-renders sixty times a second.
Variations
one state per section
- 01A URL, in a card
- 02shape="fluid": pills, not cells
- 03shape="dot": a field of discs
- 04A mark in the centre — and the item sizes the hole
- 05reveal: blooms once from the centre
- 06QrCodeScan: leans to the pointer, a beam sweeps while hovered
- 07The smallest code: one character
- 08An otpauth URI — what totp-setup encodes
- 09Level H: 30% damage tolerance
- 10margin={0}, quiet zone from the frame
- 11Recoloured with text-primary
- 12Loading, at the code's size
- 13Too long to encode
A URL, in a card
Scan it with your phone: it opens this registry. The value is 32 bytes and lands in version 3 (29 modules a side) at level M, the smallest that fits — the encoder never picks a denser code than the payload needs. There is no size prop: the SVG is a square of the frame's width, so a wider card is a bigger, equally sharp code. It is role="img" with a label that says what scanning does, which is the item's accessible content; the drawing has nothing else to read.
shape="fluid": pills, not cells
Same matrix, different ink. Every run of dark modules is one pill — a corner is rounded only where a module has no dark neighbour along both of that corner's edges, so the shape follows the data with no run measured — and the notch where two runs meet is filled so the blobs join instead of kissing. The eyes are rounded rings with a rounded pupil in the same idiom. It scans because a decoder samples the centre of each module and every pill covers every centre it owns; scan this one to check. This is the code most people mean by a designed QR, with no library and no canvas.
shape="dot": a field of discs
Each module is a disc 0.84 of its cell (QR_DOT), which leaves a visible gap between neighbours and still covers the sampled centre with margin — the number is the one dial the shape has and it is a constant, because a smaller dot is a code that scans on one phone and not another. The eyes are full rings so they read as one drawing with the field rather than three squares stapled to it.
A mark in the centre — and the item sizes the hole
Pass children and three things happen together: the level is forced to H, a centred square of qrHoleSide(size) modules is cleared (7.8% of the code — this one clears 9×9 of 33), and the mark is laid over it with a module of breathing room, aria-hidden because the label already says what the code is. Doing one of those without the others is how a branded QR ships unscannable; here they are one decision. The mark's ground must stay light — a dark tile filling the hole is a block of false modules. Scan it.
reveal: blooms once from the centre
Reload to see it once — it does not replay, and that is the decision: a QR that keeps assembling itself beside a form is motion in the periphery of someone trying to open their camera. The modules are grouped into rings by distance from the centre and each ring's start is written as a custom property from QR_BLOOM_SECONDS / rings, so version 1 and version 10 both bloom in 0.9s and the eyes land last. Nothing is ever removed from the document: a ring waiting its turn is opacity 0, so the code is complete in the HTML from the first byte and reduced motion, a missing stylesheet, print and a headless render all get the finished code instantly. Needs styles/automindz-qr.css imported once.
QrCodeScan: leans to the pointer, a beam sweeps while hovered
Move the pointer over it. The card tilts up to 8° toward the pointer — a card in the hand turning to the light, not a carousel — and a scanner line sweeps top to bottom at 0.8Hz for as long as you stay, then stops. It is a separate file with the only client boundary in the item: the wrapper writes two custom properties from the pointer and the stylesheet does the rest, so the code inside is untouched and scans the same. Not a focus stop and not a control; inside a link the beam also runs on focus-within. Reduced motion: flat, no beam, nothing to check. Touch gets no tilt — a finger covering the code is not a position to lean toward.
The smallest code: one character
Version 1 — 21 modules a side — holds 14 bytes at level M. A short payload makes a coarse code that scans from across a room, which is the argument for short URLs and a redirect over a long one encoded verbatim.
An otpauth URI — what totp-setup encodes
A 120-byte enrolment URI lands in version 7. Version 7 and up carry an 18-bit version field in the two corners beside the finders, and from version 7 the alignment grid is three across — both visible here and neither in the URL above. Scanning it with an authenticator adds an account; the secret is a documentation example.
Level H: 30% damage tolerance
The same URL at the highest error-correction level. It costs a version — more codewords are error correction — which is why M is the default and H is for a code that will be printed small, laminated, or have a mark laid over it. Pass children for a centre mark and the item forces H for you — see the slot variant below.
margin={0}, quiet zone from the frame
The quiet zone is drawn inside the SVG by default (four modules, the spec's minimum) so a bare QrCode scans. Here it is off and the padding around it comes from the frame instead — the right call when the code sits in a padded card, because two quiet zones make a small code smaller for nothing. Whatever supplies the gap, it has to be at least four modules wide and the same colour as the ground.
Recoloured with text-primary
The modules are currentColor, so the ink is one class on the code or on anything above it. One saturated colour on a light ground scans; a mid-grey (text-muted-foreground on bg-muted) does not — a scanner wants contrast, not a palette. A dark theme inverting both together is fine.
Loading, at the code's size
A square skeleton the width of the frame, which is exactly what the code will be — so a code that arrives from the server (an enrolment secret, a payment link) lands with no jump.
Too long to encode
Version 10 is the ceiling — 213 bytes at level M — and past it the component renders the reason in place as a role="alert" in the same square footprint, rather than throwing through the page or drawing a code too dense to scan. This is a 400-byte value. The fix is a shorter URL, which is a design decision the item refuses to make for you.
Too long for a QR code. 397 bytes is more than a version 10 code holds at level M (213). Shorten the value to at most 213 bytes at level M.
