QR Code
Renders a scannable QR code from a string entirely client-side with no npm dependency, using a vendored MIT Reed-Solomon encoder and an inline themed SVG.
Preview
<x-ui.qr-code value="https://brok.dev" :size="180" />
Installation
php artisan ui:add qr-code
Note
This component ships an Alpine behavior module at
resources/js/ui/qr-code.js. Import it once from your bundle so it registers on alpine:init:
import './qr-code.js';
Registry contract
php artisan ui:add qr-code
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/qr-code.blade.php -
resources/js/ui/qr-code.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: QR Code (`qr-code`)
Renders a scannable QR code from a string entirely client-side with no npm dependency, using a vendored MIT Reed-Solomon encoder and an inline themed SVG.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add qr-code
```
## Usage
```blade
<x-ui.qr-code value="https://brok.dev" :size="180" />
```
## Props
- `value` (string, default ``) — Text or URL to encode.
- `size` (int, default `160`) — Rendered size in pixels.
- `level` (string, default `M`) — Error-correction level (L, M, Q, or H); higher levels tolerate more damage or a larger logo overlay at the cost of a denser code.
- `margin` (int, default `4`) — Quiet-zone margin in modules around the code.
- `fg` (mixed|null, default `null`) — Foreground module colour; defaults to the current foreground token.
- `bg` (mixed|null, default `null`) — Background colour; defaults to transparent or the surface token.
- `label` (string, default `QR code`) — Accessible label describing what the code encodes.
- `style` (string, default `square`) — Module dot style: square or a rounded variant.
- `eyes` (mixed|null, default `null`) — Optional custom styling for the three corner finder patterns ("eyes").
- `logo` (mixed|null, default `null`) — Optional image URL rendered in the center of the code, sized within the error-correction tolerance.
- `logoSize` (float, default `0.22`) — Logo size as a fraction of the code's total width.
- `caption` (mixed|null, default `null`) — Optional text shown under the code.
## Use when
- Use when image or video selection, preview, or media-based comparison is part of the task.
- Encoding a URL, contact card, or short text as a scannable QR code rendered entirely client-side.
- Needing the QR code styled with brand colours, a rounded dot style, or a center logo while staying scannable.
## Avoid when
- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- The value is too long for a QR code to encode reliably at a reasonable size (very long text); shorten it or link to it instead.
- A barcode or other 1D symbology is required instead of a 2D QR code.
- Displaying the raw text or link rather than encoding it; use code-block or input-copy instead.
## Anti-patterns
- Requiring media-heavy interaction when text is faster
## Rules
- Use the `<brok:qr-code>` tag (or `<x-ui.qr-code>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/qr-code
- Registry JSON (files, props, contract): https://brokui.dev/r/open/qr-code.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<div class="flex flex-wrap items-center justify-center gap-8">
<x-ui.qr-code value="https://brok.dev" :size="180" style="rounded" logo="{{ asset('favicon-96x96.png') }}" :label="__('QR code with a centre logo')" />
<x-ui.qr-code value="https://brok.dev/pay/INV-2049" :size="160" style="dots" :caption="__('Scan to pay')" :label="__('QR code in a card: scan to pay')" />
</div>
<div class="flex flex-wrap items-center justify-center gap-8">
<x-ui.qr-code value="https://brok.dev" :size="150" style="dots" :label="__('QR code, dot modules')" />
<x-ui.qr-code value="https://brok.dev" :size="150" style="rounded" :label="__('QR code, rounded modules')" />
<x-ui.qr-code value="https://brok.dev" :size="150" style="square" eyes="rounded" :label="__('QR code, rounded eyes')" />
</div>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | Text or URL to encode. | |
| size | int | 160 | Rendered size in pixels. |
| level | string | M | Error-correction level (L, M, Q, or H); higher levels tolerate more damage or a larger logo overlay at the cost of a denser code. |
| margin | int | 4 | Quiet-zone margin in modules around the code. |
| fg | mixed | null | null | Foreground module colour; defaults to the current foreground token. |
| bg | mixed | null | null | Background colour; defaults to transparent or the surface token. |
| label | string | QR code | Accessible label describing what the code encodes. |
| style | string | square | Module dot style: square or a rounded variant. |
| eyes | mixed | null | null | Optional custom styling for the three corner finder patterns ("eyes"). |
| logo | mixed | null | null | Optional image URL rendered in the center of the code, sized within the error-correction tolerance. |
| logoSize | float | 0.22 | Logo size as a fraction of the code's total width. |
| caption | mixed | null | null | Optional text shown under the code. |
Slots
default—
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Encodes the value client-side with a vendored Reed-Solomon encoder and renders it as an inline SVG, so no network request or npm QR library is required.
- A higher error-correction level is needed when a logo overlay is used, since the logo covers part of the code's data area.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Select, preview, or compare visual media.
Use when
- Use when image or video selection, preview, or media-based comparison is part of the task.
- Encoding a URL, contact card, or short text as a scannable QR code rendered entirely client-side.
- Needing the QR code styled with brand colours, a rounded dot style, or a center logo while staying scannable.
Avoid when
- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- The value is too long for a QR code to encode reliably at a reasonable size (very long text); shorten it or link to it instead.
- A barcode or other 1D symbology is required instead of a 2D QR code.
- Displaying the raw text or link rather than encoding it; use code-block or input-copy instead.
Use instead
- Text or list representation
Anti-patterns
- Requiring media-heavy interaction when text is faster
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- managed
- Focus
none
- The rendered SVG carries an accessible label built from the label prop, so screen readers announce what the code represents rather than treating it as an unlabelled image.
- An optional caption gives sighted users who cannot scan the code a text alternative for its content.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<div wire:ignore>
<x-ui.qr-code value="https://brok.dev" :size="180" />
</div>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'value' => '',
'size' => 160,
'level' => 'M',
'margin' => 4,
'fg' => null,
'bg' => null,
'label' => 'QR code',
// Module style: square | dots | rounded (joined, pill-like tiles).
'style' => 'square',
// Finder-pattern style: square | rounded | circle. Follows `style` when null.
'eyes' => null,
// Centre logo URL (http(s) or data:image). Forces the H error-correction
// level and clears a window of `logoSize` × the code's width behind it.
'logo' => null,
'logoSize' => 0.22,
// Wrap the code in a card with a caption under it (e.g. "Scan to pay").
'caption' => null,
])
@php
// Coerce props once so SSR markup and the JS config stay in lock-step.
// `fg`/`bg` are passed through untouched here — they are validated JS-side
// (uiQrCodeSafeColor in qr-code.js) against a strict colour whitelist before
// they are ever interpolated into the innerHTML-assigned SVG string.
$valueStr = (string) $value;
$sizeInt = (int) $size;
$levelStr = in_array(strtoupper((string) $level), ['L', 'M', 'Q', 'H'], true)
? strtoupper((string) $level)
: 'M';
$marginInt = max(0, (int) $margin);
$styleStr = in_array($style, ['square', 'dots', 'rounded'], true) ? $style : 'square';
$eyesStr = in_array($eyes, ['square', 'rounded', 'circle'], true) ? $eyes : null;
@endphp
<div
data-slot="qr-code"
data-style="{{ $styleStr }}"
role="img"
aria-label="{{ $label }}"
x-data="uiQrCode({ value: @js($valueStr), size: @js($sizeInt), level: @js($levelStr), margin: @js($marginInt), fg: @js($fg), bg: @js($bg), style: @js($styleStr), eyes: @js($eyesStr), logo: @js($logo), logoSize: @js((float) $logoSize) })"
x-init="render()"
{{ $attributes->merge(['class' => $caption !== null
? 'inline-flex flex-col items-center gap-3 rounded-lg border border-border bg-card p-6 text-foreground shadow-sm'
: 'inline-flex items-center justify-center text-foreground']) }}
>
<div x-ref="canvas" aria-hidden="true" :style="`width:${size}px;height:${size}px;`" style="width: {{ $sizeInt }}px; height: {{ $sizeInt }}px;"></div>
@if ($caption !== null)
<p data-slot="qr-code-caption" class="text-sm font-medium text-muted-foreground" aria-hidden="true">{{ $caption }}</p>
@endif
</div>
/**
* QR Code behavior + vendored encoder.
*
* Renders a scannable QR code entirely client-side with NO npm dependency.
* The encoder below is a compact port of Project Nayuki's MIT-licensed
* "QR Code generator" (byte/UTF-8 mode path), supporting versions 1–40,
* error-correction levels L/M/Q/H, automatic version selection, the 8 mask
* patterns with penalty-based selection, finder/timing/alignment patterns,
* format + version information, and Reed–Solomon error correction.
*
* The Alpine component (`uiQrCode`) builds an inline SVG from the boolean
* module matrix and injects it into the `x-ref="canvas"` element. Dark modules
* default to `currentColor` so the code inherits the theme foreground; the
* background is transparent by default. Modules render as squares, dots or
* joined rounded tiles; the finder "eyes" as squares, rounded squares or
* circles; an optional centre logo clears a window and forces the H level.
* Self-registers on `alpine:init`.
*
* Vendored: QR Code generator library (TypeScript -> JS port)
* Copyright (c) Project Nayuki. (MIT License)
* https://www.nayuki.io/page/qr-code-generator-library
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* - The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* - The Software is provided "as is", without warranty of any kind, express or
* implied, including but not limited to the warranties of merchantability,
* fitness for a particular purpose and noninfringement. In no event shall the
* authors or copyright holders be liable for any claim, damages or other
* liability, whether in an action of contract, tort or otherwise, arising
* from, out of or in connection with the Software or the use or other
* dealings in the Software.
*/
const QrCodeGen = (() => {
'use strict';
/* ---- Reed–Solomon helpers ------------------------------------------- */
function reedSolomonComputeDivisor(degree) {
if (degree < 1 || degree > 255) throw new RangeError('Degree out of range');
// The polynomial coefficients are stored from highest to lowest power.
const result = [];
for (let i = 0; i < degree - 1; i++) result.push(0);
result.push(1); // Start with the monomial x^0.
let root = 1;
for (let i = 0; i < degree; i++) {
for (let j = 0; j < result.length; j++) {
result[j] = reedSolomonMultiply(result[j], root);
if (j + 1 < result.length) result[j] ^= result[j + 1];
}
root = reedSolomonMultiply(root, 0x02);
}
return result;
}
function reedSolomonComputeRemainder(data, divisor) {
const result = divisor.map(() => 0);
for (const b of data) {
const factor = b ^ result.shift();
result.push(0);
divisor.forEach((coef, i) => (result[i] ^= reedSolomonMultiply(coef, factor)));
}
return result;
}
function reedSolomonMultiply(x, y) {
if (x >>> 8 !== 0 || y >>> 8 !== 0) throw new RangeError('Byte out of range');
let z = 0;
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d);
z ^= ((y >>> i) & 1) * x;
}
return z;
}
/* ---- QrSegment ------------------------------------------------------- */
function getBit(x, i) {
return ((x >>> i) & 1) !== 0;
}
function appendBits(val, len, bb) {
if (len < 0 || len > 31 || val >>> len !== 0) throw new RangeError('Value out of range');
for (let i = len - 1; i >= 0; i--) bb.push((val >>> i) & 1);
}
function toUtf8ByteArray(str) {
str = encodeURI(str);
const result = [];
for (let i = 0; i < str.length; i++) {
if (str.charAt(i) !== '%') {
result.push(str.charCodeAt(i));
} else {
result.push(parseInt(str.substring(i + 1, i + 3), 16));
i += 2;
}
}
return result;
}
class QrSegment {
constructor(mode, numChars, bitData) {
this.mode = mode;
this.numChars = numChars;
this.bitData = bitData.slice();
if (numChars < 0) throw new RangeError('Invalid argument');
}
static makeBytes(data) {
const bb = [];
for (const b of data) appendBits(b, 8, bb);
return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
}
static getTotalBits(segs, version) {
let result = 0;
for (const seg of segs) {
const ccbits = seg.mode.numCharCountBits(version);
if (seg.numChars >= 1 << ccbits) return Infinity;
result += 4 + ccbits + seg.bitData.length;
}
return result;
}
}
QrSegment.Mode = {
BYTE: {
modeBits: 0x4,
numBitsCharCount: [8, 16, 16],
numCharCountBits(ver) {
return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
},
},
};
/* ---- QrCode ---------------------------------------------------------- */
class QrCode {
constructor(version, errCorLvl, dataCodewords, msk) {
this.version = version;
this.errorCorrectionLevel = errCorLvl;
this.modules = [];
this.isFunction = [];
if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
throw new RangeError('Version value out of range');
if (msk < -1 || msk > 7) throw new RangeError('Mask value out of range');
this.size = version * 4 + 17;
const row = [];
for (let i = 0; i < this.size; i++) row.push(false);
for (let i = 0; i < this.size; i++) {
this.modules.push(row.slice());
this.isFunction.push(row.slice());
}
this.drawFunctionPatterns();
const allCodewords = this.addEccAndInterleave(dataCodewords);
this.drawCodewords(allCodewords);
if (msk === -1) {
let minPenalty = 1000000000;
for (let i = 0; i < 8; i++) {
this.applyMask(i);
this.drawFormatBits(i);
const penalty = this.getPenaltyScore();
if (penalty < minPenalty) {
msk = i;
minPenalty = penalty;
}
this.applyMask(i); // Undo (XOR is its own inverse).
}
}
this.mask = msk;
this.applyMask(msk);
this.drawFormatBits(msk);
this.isFunction = [];
}
static encodeText(text, ecl) {
const segs = [QrSegment.makeBytes(toUtf8ByteArray(text))];
return QrCode.encodeSegments(segs, ecl);
}
static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) {
let version, dataUsedBits;
for (version = minVersion; ; version++) {
const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8;
const usedBits = QrSegment.getTotalBits(segs, version);
if (usedBits <= dataCapacityBits) {
dataUsedBits = usedBits;
break;
}
if (version >= maxVersion) throw new RangeError('Data too long');
}
for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) {
if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8) ecl = newEcl;
}
const bb = [];
for (const seg of segs) {
appendBits(seg.mode.modeBits, 4, bb);
appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
for (const b of seg.bitData) bb.push(b);
}
const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8;
appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
appendBits(0, (8 - (bb.length % 8)) % 8, bb);
for (let padByte = 0xec; bb.length < dataCapacityBits; padByte ^= 0xec ^ 0x11)
appendBits(padByte, 8, bb);
const dataCodewords = [];
while (dataCodewords.length * 8 < bb.length) dataCodewords.push(0);
bb.forEach((b, i) => (dataCodewords[i >>> 3] |= b << (7 - (i & 7))));
return new QrCode(version, ecl, dataCodewords, mask);
}
getModule(x, y) {
return x >= 0 && x < this.size && y >= 0 && y < this.size && this.modules[y][x];
}
drawFunctionPatterns() {
for (let i = 0; i < this.size; i++) {
this.setFunctionModule(6, i, i % 2 === 0);
this.setFunctionModule(i, 6, i % 2 === 0);
}
this.drawFinderPattern(3, 3);
this.drawFinderPattern(this.size - 4, 3);
this.drawFinderPattern(3, this.size - 4);
const alignPatPos = this.getAlignmentPatternPositions();
const numAlign = alignPatPos.length;
for (let i = 0; i < numAlign; i++) {
for (let j = 0; j < numAlign; j++) {
if (!((i === 0 && j === 0) || (i === 0 && j === numAlign - 1) || (i === numAlign - 1 && j === 0)))
this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
}
}
this.drawFormatBits(0);
this.drawVersion();
}
drawFormatBits(mask) {
const data = (this.errorCorrectionLevel.formatBits << 3) | mask;
let rem = data;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
const bits = ((data << 10) | rem) ^ 0x5412;
for (let i = 0; i <= 5; i++) this.setFunctionModule(8, i, getBit(bits, i));
this.setFunctionModule(8, 7, getBit(bits, 6));
this.setFunctionModule(8, 8, getBit(bits, 7));
this.setFunctionModule(7, 8, getBit(bits, 8));
for (let i = 9; i < 15; i++) this.setFunctionModule(14 - i, 8, getBit(bits, i));
for (let i = 0; i < 8; i++) this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
for (let i = 8; i < 15; i++) this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
this.setFunctionModule(8, this.size - 8, true);
}
drawVersion() {
if (this.version < 7) return;
let rem = this.version;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const bits = (this.version << 12) | rem;
for (let i = 0; i < 18; i++) {
const color = getBit(bits, i);
const a = this.size - 11 + (i % 3);
const b = Math.floor(i / 3);
this.setFunctionModule(a, b, color);
this.setFunctionModule(b, a, color);
}
}
drawFinderPattern(x, y) {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const dist = Math.max(Math.abs(dx), Math.abs(dy));
const xx = x + dx;
const yy = y + dy;
if (xx >= 0 && xx < this.size && yy >= 0 && yy < this.size)
this.setFunctionModule(xx, yy, dist !== 2 && dist !== 4);
}
}
}
drawAlignmentPattern(x, y) {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++)
this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
}
setFunctionModule(x, y, isDark) {
this.modules[y][x] = isDark;
this.isFunction[y][x] = true;
}
addEccAndInterleave(data) {
const ver = this.version;
const ecl = this.errorCorrectionLevel;
if (data.length !== QrCode.getNumDataCodewords(ver, ecl)) throw new RangeError('Invalid argument');
const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
const numShortBlocks = numBlocks - (rawCodewords % numBlocks);
const shortBlockLen = Math.floor(rawCodewords / numBlocks);
const blocks = [];
const rsDiv = reedSolomonComputeDivisor(blockEccLen);
for (let i = 0, k = 0; i < numBlocks; i++) {
const dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
k += dat.length;
const ecc = reedSolomonComputeRemainder(dat, rsDiv);
if (i < numShortBlocks) dat.push(0);
blocks.push(dat.concat(ecc));
}
const result = [];
for (let i = 0; i < blocks[0].length; i++) {
blocks.forEach((block, j) => {
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(block[i]);
});
}
return result;
}
drawCodewords(data) {
if (data.length !== Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
throw new RangeError('Invalid argument');
let i = 0;
for (let right = this.size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < this.size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? this.size - 1 - vert : vert;
if (!this.isFunction[y][x] && i < data.length * 8) {
this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
i++;
}
}
}
}
}
applyMask(mask) {
if (mask < 0 || mask > 7) throw new RangeError('Mask value out of range');
for (let y = 0; y < this.size; y++) {
for (let x = 0; x < this.size; x++) {
let invert;
switch (mask) {
case 0: invert = (x + y) % 2 === 0; break;
case 1: invert = y % 2 === 0; break;
case 2: invert = x % 3 === 0; break;
case 3: invert = (x + y) % 3 === 0; break;
case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
case 5: invert = ((x * y) % 2) + ((x * y) % 3) === 0; break;
case 6: invert = (((x * y) % 2) + ((x * y) % 3)) % 2 === 0; break;
case 7: invert = (((x + y) % 2) + ((x * y) % 3)) % 2 === 0; break;
default: throw new Error('Unreachable');
}
if (!this.isFunction[y][x] && invert) this.modules[y][x] = !this.modules[y][x];
}
}
}
getPenaltyScore() {
let result = 0;
const size = this.size;
for (let y = 0; y < size; y++) {
let runColor = false;
let runX = 0;
const runHistory = [0, 0, 0, 0, 0, 0, 0];
for (let x = 0; x < size; x++) {
if (this.modules[y][x] === runColor) {
runX++;
if (runX === 5) result += QrCode.PENALTY_N1;
else if (runX > 5) result++;
} else {
this.finderPenaltyAddHistory(runX, runHistory);
if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
runColor = this.modules[y][x];
runX = 1;
}
}
result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
}
for (let x = 0; x < size; x++) {
let runColor = false;
let runY = 0;
const runHistory = [0, 0, 0, 0, 0, 0, 0];
for (let y = 0; y < size; y++) {
if (this.modules[y][x] === runColor) {
runY++;
if (runY === 5) result += QrCode.PENALTY_N1;
else if (runY > 5) result++;
} else {
this.finderPenaltyAddHistory(runY, runHistory);
if (!runColor) result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
runColor = this.modules[y][x];
runY = 1;
}
}
result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
}
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const color = this.modules[y][x];
if (color === this.modules[y][x + 1] && color === this.modules[y + 1][x] && color === this.modules[y + 1][x + 1])
result += QrCode.PENALTY_N2;
}
}
let dark = 0;
for (const row of this.modules) dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
const total = size * size;
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
result += k * QrCode.PENALTY_N4;
return result;
}
getAlignmentPatternPositions() {
if (this.version === 1) return [];
const numAlign = Math.floor(this.version / 7) + 2;
const step = this.version === 32 ? 26 : Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
const result = [6];
for (let pos = this.size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
return result;
}
static getNumRawDataModules(ver) {
if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION) throw new RangeError('Version number out of range');
let result = (16 * ver + 128) * ver + 64;
if (ver >= 2) {
const numAlign = Math.floor(ver / 7) + 2;
result -= (25 * numAlign - 10) * numAlign - 55;
if (ver >= 7) result -= 36;
}
return result;
}
static getNumDataCodewords(ver, ecl) {
return (
Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] * QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver]
);
}
finderPenaltyCountPatterns(runHistory) {
const n = runHistory[1];
const core =
n > 0 &&
runHistory[2] === n &&
runHistory[3] === n * 3 &&
runHistory[4] === n &&
runHistory[5] === n;
return (
(core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0) +
(core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0)
);
}
finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) {
if (currentRunColor) {
this.finderPenaltyAddHistory(currentRunLength, runHistory);
currentRunLength = 0;
}
currentRunLength += this.size;
this.finderPenaltyAddHistory(currentRunLength, runHistory);
return this.finderPenaltyCountPatterns(runHistory);
}
finderPenaltyAddHistory(currentRunLength, runHistory) {
if (runHistory[0] === 0) currentRunLength += this.size;
runHistory.pop();
runHistory.unshift(currentRunLength);
}
}
QrCode.MIN_VERSION = 1;
QrCode.MAX_VERSION = 40;
QrCode.PENALTY_N1 = 3;
QrCode.PENALTY_N2 = 3;
QrCode.PENALTY_N3 = 40;
QrCode.PENALTY_N4 = 10;
QrCode.ECC_CODEWORDS_PER_BLOCK = [
// Version: (note: index 0 is for padding, and is set to an illegal value)
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // L
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28], // M
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // Q
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // H
];
QrCode.NUM_ERROR_CORRECTION_BLOCKS = [
// Version: (note: index 0 is for padding, and is set to an illegal value)
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25], // L
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49], // M
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68], // Q
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // H
];
QrCode.Ecc = {
LOW: { ordinal: 0, formatBits: 1 },
MEDIUM: { ordinal: 1, formatBits: 0 },
QUARTILE: { ordinal: 2, formatBits: 3 },
HIGH: { ordinal: 3, formatBits: 2 },
};
return { QrCode, QrSegment };
})();
/* --------------------------------------------------------------------------
* SVG builder + Alpine component
* ------------------------------------------------------------------------ */
// Colours are interpolated into SVG markup that lands in innerHTML, so they are
// a trust boundary: accept only a strict colour grammar (hex, rgb[a]/hsl[a],
// var(--token), or a bare CSS keyword) and drop anything else. Prevents
// attribute breakout if a consumer ever binds these props to user input.
function uiQrCodeSafeColor(value) {
if (typeof value !== 'string') return null;
const v = value.trim();
const ok =
/^#[0-9a-f]{3,8}$/i.test(v) ||
/^(rgb|rgba|hsl|hsla)\([\d\s.,%/]+\)$/i.test(v) ||
/^var\(--[\w-]+\)$/.test(v) ||
/^[a-z]+$/i.test(v);
return ok ? v : null;
}
// Only http(s) and data:image URLs may become the centre logo's href.
function uiQrCodeSafeUrl(value) {
if (typeof value !== 'string') return null;
const v = value.trim();
if (!/^(https?:\/\/[^\s"'<>]+|data:image\/(png|jpe?g|gif|webp|svg\+xml);[^\s"'<>]+)$/i.test(v)) return null;
return v.replace(/&/g, '&');
}
// Finder ("eye") pattern outlines: the 7×7 ring and the 3×3 pupil, in module
// units relative to the eye's top-left corner.
function uiQrCodeEye(x, y, style) {
if (style === 'circle') {
return (
`<path fill-rule="evenodd" d="M${x + 3.5},${y} a3.5,3.5 0 1,0 0.0001,0 z ` +
`M${x + 3.5},${y + 1} a2.5,2.5 0 1,1 -0.0001,0 z"/>` +
`<circle cx="${x + 3.5}" cy="${y + 3.5}" r="1.5"/>`
);
}
const rx = style === 'rounded' ? 1.6 : 0;
const irx = style === 'rounded' ? 0.8 : 0;
return (
`<path fill-rule="evenodd" d="M${x + rx},${y} h${7 - 2 * rx} a${rx},${rx} 0 0 1 ${rx},${rx} v${7 - 2 * rx} ` +
`a${rx},${rx} 0 0 1 -${rx},${rx} h-${7 - 2 * rx} a${rx},${rx} 0 0 1 -${rx},-${rx} v-${7 - 2 * rx} a${rx},${rx} 0 0 1 ${rx},-${rx} z ` +
`M${x + 1 + irx},${y + 1} h${5 - 2 * irx} a${irx},${irx} 0 0 1 ${irx},${irx} v${5 - 2 * irx} a${irx},${irx} 0 0 1 -${irx},${irx} ` +
`h-${5 - 2 * irx} a${irx},${irx} 0 0 1 -${irx},-${irx} v-${5 - 2 * irx} a${irx},${irx} 0 0 1 ${irx},-${irx} z"/>` +
`<rect x="${x + 2}" y="${y + 2}" width="3" height="3" rx="${style === 'rounded' ? 0.7 : 0}"/>`
);
}
function uiQrCodeBuildSvg({ value, size, level, margin, fg, bg, style = 'square', eyes = null, logo = null, logoSize = 0.22 }) {
const eccMap = {
L: QrCodeGen.QrCode.Ecc.LOW,
M: QrCodeGen.QrCode.Ecc.MEDIUM,
Q: QrCodeGen.QrCode.Ecc.QUARTILE,
H: QrCodeGen.QrCode.Ecc.HIGH,
};
const safeLogo = uiQrCodeSafeUrl(logo);
// A logo covers modules, so it needs the strongest error correction.
const ecc = safeLogo ? QrCodeGen.QrCode.Ecc.HIGH : eccMap[String(level).toUpperCase()] || QrCodeGen.QrCode.Ecc.MEDIUM;
const qr = QrCodeGen.QrCode.encodeText(String(value), ecc);
const count = qr.size;
const m = Math.max(0, Math.floor(margin));
const dim = count + m * 2;
const moduleStyle = ['square', 'dots', 'rounded'].includes(style) ? style : 'square';
const eyeStyle = ['square', 'rounded', 'circle'].includes(eyes) ? eyes : moduleStyle === 'square' ? 'square' : moduleStyle === 'dots' ? 'circle' : 'rounded';
// The three finder patterns are drawn as shapes of their own; every other
// dark module is a square, a dot or a rounded tile that joins its
// horizontal / vertical neighbours.
const eyeAt = [[0, 0], [count - 7, 0], [0, count - 7]];
const inEye = (x, y) => eyeAt.some(([ex, ey]) => x >= ex && x < ex + 7 && y >= ey && y < ey + 7);
// Clear a centred window for the logo (in modules, rounded up to odd so it
// is symmetric) — the H level tolerates ~30% loss, the window stays < 20%.
const window_ = safeLogo ? Math.min(count - 14, 2 * Math.floor((count * Math.min(0.28, Math.max(0.1, logoSize))) / 2) + 1) : 0;
const w0 = (count - window_) / 2;
const inLogo = (x, y) => window_ > 0 && x >= w0 && x < w0 + window_ && y >= w0 && y < w0 + window_;
let path = '';
for (let y = 0; y < count; y++) {
for (let x = 0; x < count; x++) {
if (!qr.getModule(x, y) || inEye(x, y) || inLogo(x, y)) continue;
const px = x + m;
const py = y + m;
if (moduleStyle === 'dots') {
path += `M${px + 0.5},${py + 0.08}a0.42,0.42 0 1,0 0.0001,0z`;
} else if (moduleStyle === 'rounded') {
const right = x + 1 < count && qr.getModule(x + 1, y) && !inEye(x + 1, y) && !inLogo(x + 1, y);
const down = y + 1 < count && qr.getModule(x, y + 1) && !inEye(x, y + 1) && !inLogo(x, y + 1);
// Every subpath winds the same way (counter-clockwise) so the
// non-zero fill unions the disc and its bridges instead of
// punching holes where they overlap.
path += `M${px + 0.5},${py + 0.06}a0.44,0.44 0 1,0 0.0001,0z`;
if (right) path += `M${px + 0.5},${py + 0.06}v0.88h1v-0.88z`;
if (down) path += `M${px + 0.06},${py + 0.5}v1h0.88v-1z`;
} else {
path += `M${px},${py}h1v1h-1z`;
}
}
}
const fill = fg ? fg : 'currentColor';
const bgRect = bg ? `<rect width="${dim}" height="${dim}" fill="${bg}"/>` : '';
const eyesMarkup = eyeAt.map(([ex, ey]) => uiQrCodeEye(ex + m, ey + m, eyeStyle)).join('');
const logoMarkup = safeLogo
? `<rect x="${w0 + m + 0.5}" y="${w0 + m + 0.5}" width="${window_ - 1}" height="${window_ - 1}" rx="1" fill="${bg || 'var(--background-color, hsl(var(--background)))'}"/>` +
`<image href="${safeLogo}" x="${w0 + m + 1}" y="${w0 + m + 1}" width="${window_ - 2}" height="${window_ - 2}" preserveAspectRatio="xMidYMid meet"/>`
: '';
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" ` +
`viewBox="0 0 ${dim} ${dim}" shape-rendering="${moduleStyle === 'square' ? 'crispEdges' : 'geometricPrecision'}" ` +
`preserveAspectRatio="xMidYMid meet" focusable="false" aria-hidden="true">` +
bgRect +
`<g fill="${fill}"><path d="${path}"/>${eyesMarkup}</g>` +
logoMarkup +
`</svg>`
);
}
if (typeof document !== 'undefined') document.addEventListener('alpine:init', () => {
window.Alpine.data('uiQrCode', (config = {}) => ({
value: config.value ?? '',
size: Number(config.size) || 160,
level: config.level ?? 'M',
margin: config.margin ?? 4,
fg: uiQrCodeSafeColor(config.fg ?? null),
bg: uiQrCodeSafeColor(config.bg ?? null),
style: config.style ?? 'square',
eyes: config.eyes ?? null,
logo: config.logo ?? null,
logoSize: Number(config.logoSize) || 0.22,
render() {
const target = this.$refs.canvas;
if (!target) return;
if (this.value === '' || this.value == null) {
// Graceful empty state: a bordered placeholder, no fake matrix.
target.innerHTML =
`<div class="flex items-center justify-center rounded-md border border-dashed ` +
`border-border text-muted-foreground text-xs" ` +
`style="width:${this.size}px;height:${this.size}px;">No value</div>`;
return;
}
try {
target.innerHTML = uiQrCodeBuildSvg({
value: this.value,
size: this.size,
level: this.level,
margin: this.margin,
fg: this.fg,
bg: this.bg,
style: this.style,
eyes: this.eyes,
logo: this.logo,
logoSize: this.logoSize,
});
} catch (e) {
target.innerHTML =
`<div class="flex items-center justify-center rounded-md border border-dashed ` +
`border-destructive text-destructive text-xs" ` +
`style="width:${this.size}px;height:${this.size}px;">Value too long</div>`;
}
},
}));
});
// Expose the encoder + SVG builder for non-Alpine / test consumers.
if (typeof window !== 'undefined') {
window.uiQrCodeGen = QrCodeGen;
window.uiQrCodeBuildSvg = uiQrCodeBuildSvg;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { QrCodeGen, uiQrCodeBuildSvg };
}
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.1.3 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0