Skip to content
Brok UI

Loading…

No results

QR Code

Open source

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.

Version
v1.1.3
Stability
stable
License
MIT
Related
Input Copy
Card
Code Block

Preview

previews.components.qr-code.default.blade.php Blade
<x-ui.qr-code value="https://brok.dev" :size="180" />

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/qr-code.blade.php
  • js resources/js/ui/qr-code.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

qr-code.md
# 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

styles.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

qr-code qr-code-caption

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

Media selection and presentation

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
root qr-code-caption
Theming hooks
qr-code qr-code-caption

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<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.

resources/views/components/ui/qr-code.blade.php Blade
@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>
resources/js/ui/qr-code.js JS
/**
 * 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, '&amp;');
}

// 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