Anatomi QRIS
An explainer that builds QR codes step by step and lights up the squares behind each QRIS field

- stages on one draggable slider
- 9
- network requests while you use it
- 0
- QR versions matched against a reference library
- 1–15
- to rebuild the largest code per keystroke
- < 8 ms
On this page
The problem
People scan QR codes every day and have never seen one built. Online explainers stop at "here is a bitstream, here is a matrix". Error correction and masking stay black boxes, even in tutorials written for developers.
In Indonesia there is a second gap. QRIS, the national QR payment standard, is on the counter of almost every warung. The code behind the sticker is a plain, readable string that names the merchant, the payment provider and a checksum. Existing QRIS parsers show that string as a table and stop there.
I wanted to join the two. Select "merchant name" in the payment data, and the exact black squares carrying those letters light up in the code. The app is for two readers at once: a curious Indonesian with no technical background, and a developer who wants to check every number by hand.
What I built
The encoder remembers where every bit went
I wrote the QR encoder myself so it could keep a record as it builds. For every square it records why that square is dark or light: which byte and which bit it carries, or which fixed pattern it belongs to, and whether masking flipped it. Every feature reads from that one record. Field highlighting, damage tracking, the animation order and the mask comparison all come from it. I built and tested it before any screen existed, so no view could work out the layout on its own and drift from the others.
The screens show the real calculation
When the encoder adds error correction, it keeps every step of the long division. The division view draws those steps, so a reader can check one line with a pencil. The encoder also scores all eight mask patterns, even though only one wins. The mask gallery shows all eight with their penalty scores, so it always agrees with the winner.
An independent library checks every code
A test compares my output square by square with an independent QR library. It covers versions 1 to 15, all four error-correction levels, all three text modes and all eight masks, and a failure blocks the deploy. The two disagree in one place. For one penalty rule, the library rounds up where the ISO specification rounds down, which can change which mask wins. I follow the specification, and a test documents the difference so it can't go stale.
Pasted payment codes stay on your device
People paste real payment codes into this app. It makes no network requests while you use it: no analytics, no error reporting, and fonts ship with the page. A check at build time fails if anything in the output could call the network. Typed text goes into the URL so you can share a link. A pasted QRIS code never does, and the copy-link button disappears while one is loaded.
Result
Anatomi QRIS is live and public, in Indonesian and English. It walks through nine stages, from the typed text to a damaged code, on a slider you can drag. The code never moves or resizes, so halfway between two stages you see both half done. In the damage lab you scratch out squares and watch error correction repair them live. Push past its limit and it fails in front of you, with the reason. I built it in about 28 hours over two days in September 2026.
Under the hoodTechnical detail for engineers
- The encoder returns a provenance map: for each module, codeword index and bit, or function pattern, plus a mask-flip flag. It also carries inverse maps (codeword to module coordinates, payload byte to codewords). A test decodes only the highlighted codewords to prove the mapping.
- Reed-Solomon encoding emits a
divisionTrace(lead term, generator multiple, remainder before and after), which the Division view renders directly. src/engine/is pure: no React, no DOM, no randomness, no clock. It runs unmodified undernode --test.encode()for a version-15 symbol stays under 8 ms, so typing re-encodes on every keystroke with no debounce and no worker. This uses precomputed GF(256) log/antilog tables,Uint8Arrayon the hot path and a flat row-major module array.- The largest matrix is 77×77, 5,929 SVG modules. One pointer listener on the matrix group hit-tests by dividing coordinates by module size. React mounts the rects once; a painter mutates fills and caches each module's last fill.
- The oracle test demands byte-identical matrices against
qrcodeacross versions 1–15, EC levels L/M/Q/H, numeric, alphanumeric and byte modes, both sides of every version boundary, and all eight masks. It runs in CI and blocks deploy. - The rule-4 disagreement:
qrcodeusesceil(|pct − 50| / 5), while ISO/IEC 18004 Table 11 (and ZXing) use the floor. At 51.7% dark the spec charges 0 andqrcodecharges 10. The oracle forces each mask and demands agreement on all eight, and checks rules 1–3 againstqrcode's own penalty functions. - The QRIS lens activates when the input parses as a valid EMVCo Merchant-Presented Mode payload: it opens with
000201and ends in a validating CRC16 under tag 63. The TLV parser, CRC16 and tag dictionary are hand-written, with an 85-entry merchant category lookup. qrcodeandjsqrare devDependencies only, and a test fails if either is imported fromsrc/. Runtime dependencies are React, React DOM and three@fontsourcepackages.scripts/check-bundle.mjsscans the build for network APIs and unexpected URLs. The favicon is inlined as a data URI after a browser test caught a 404 on/favicon.ico.- Seven views (Matrix, Bitstream, Division, Interleave, MaskGallery, PayloadTree, DamageLab), seven bundled samples, 86 CSS design tokens, and two flat copy files for Indonesian and English. Bundle is about 83 KB gzipped against a 250 KB budget.
- Repair in the damage lab snapshots the damage standing when pressed and keeps what the decoder couldn't fix. Earlier, noise damage was derived from the slider on every read, so the repair button looked dead on the main path.
- Scope left out on purpose: Kanji mode, versions 16–40, camera scanning, styled QR codes, and static-to-dynamic QRIS conversion.
- 84 tests in 13 files, about 8,600 lines of source, 44 commits between 2 and 3 September 2026.
Screenshots
Hiring, or building something similar?
I'm open to full-time roles and select freelance work.