QR code
Real, scannable QR codes from the kit’s own encoder. Plain with SVG and PNG download, styled with dots and a logo, as a table card, a Wi-Fi card or a live generator.
One of 5 components in Media
Variants
Details
What QR code brings along, read from the registry and the source.
- Category
- Media
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/qr-code.tsx
- Brings along
- utilskiticoncopy-button
- npm packages
- None
- Languages
- English and German, 17 texts of its own
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The QrCodeProps interface from components/ui/qr-code.tsx, with the comments from the code.
variantoptionalQrCodeVariantbasicstyledcardwifigenerator
valueoptionalstringThe text or link in the code
leveloptionalQrLevelLMQH
titleoptionalstringA short title next to or above the code
descriptionoptionalstringA line under the title
displayUrloptionalstringcard: the address printed under the code, short and readable
logooptionalReact.ReactNodestyled: a picture in the middle
coloroptionalstringstyled: module color, keep it dark
ssidoptionalstringwifi: the network
passwordoptionalstringsecurityoptional'WPA' | 'WEP' | 'nopass'fileNameoptionalstringFile name of downloads, without extension
Built-in texts
QR code writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 17 texts
| Key | English | German |
|---|---|---|
| qr | English QR code | German QR-Code |
| scan | English Scan with your phone camera | German Mit der Handykamera scannen |
| svg | English Download SVG | German Als SVG sichern |
| png | English Download PNG | German Als PNG sichern |
| copy | English Copy link | German Link kopieren |
| copied | English Copied | German Kopiert |
| network | English Network | German Netzwerk |
| password | English Password | German Passwort |
| open | English Open network, no password | German Offenes Netz, ohne Passwort |
| content | English Link or text | German Link oder Text |
| level | English Error correction | German Fehlerkorrektur |
| levels | English From L to H the correction grows. A higher level survives more damage and makes the code denser. | German Von L bis H wächst die Fehlerkorrektur. Eine höhere Stufe übersteht mehr Schäden und macht den Code dichter. |
| tooLong | English This text is too long for a code. Shorten the link. | German Dieser Text ist zu lang für einen Code. Kürz den Link. |
| copyPassword | English Copy password | German Passwort kopieren |
| showPassword | English Show password | German Passwort zeigen |
| hidePassword | English Hide password | German Passwort verbergen |
| bytes | English bytes | German Byte |
Accessibility
Read from the source of the component.
- QR code handles the keys Space itself.
- Sets aria-checked, aria-invalid, aria-label, aria-labelledby, aria-live and aria-pressed, so screen readers announce purpose and state.
- Uses the roles img, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 639 lines
'use client';
import { useId, useMemo, useState } from 'react';
import { cn, useInView, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { useCopy } from '@/components/ui/copy-button';
/*
* A scannable QR code from the kit's own encoder: byte mode (UTF-8), versions
* 1 to 10, error correction L, M, Q or H, the mask with the lowest penalty.
* Drawn as SVG on a light tile, always dark on light, because scanners need
* that contrast in light and dark mode alike. Five looks: plain with
* downloads, styled with dots and a logo, a table tent card with a scan line,
* a Wi-Fi card for guests, and a generator that redraws as you type.
* Checked against a third party decoder in tests, never shipped with it.
*/
export type QrLevel = 'L' | 'M' | 'Q' | 'H';
// Per version and level: EC codewords per block, blocks in group 1, data codewords per block in group 1, blocks in group 2, data per block in group 2
const EC: Record<QrLevel, number[][]> = {
L: [[7, 1, 19, 0, 0], [10, 1, 34, 0, 0], [15, 1, 55, 0, 0], [20, 1, 80, 0, 0], [26, 1, 108, 0, 0], [18, 2, 68, 0, 0], [20, 2, 78, 0, 0], [24, 2, 97, 0, 0], [30, 2, 116, 0, 0], [18, 2, 68, 2, 69]],
M: [[10, 1, 16, 0, 0], [16, 1, 28, 0, 0], [26, 1, 44, 0, 0], [18, 2, 32, 0, 0], [24, 2, 43, 0, 0], [16, 4, 27, 0, 0], [18, 4, 31, 0, 0], [22, 2, 38, 2, 39], [22, 3, 36, 2, 37], [26, 4, 43, 1, 44]],
Q: [[13, 1, 13, 0, 0], [22, 1, 22, 0, 0], [18, 2, 17, 0, 0], [26, 2, 24, 0, 0], [18, 2, 15, 2, 16], [24, 4, 19, 0, 0], [18, 2, 14, 4, 15], [22, 4, 18, 2, 19], [20, 4, 16, 4, 17], [24, 6, 19, 2, 20]],
H: [[17, 1, 9, 0, 0], [28, 1, 16, 0, 0], [22, 2, 13, 0, 0], [16, 4, 9, 0, 0], [22, 2, 11, 2, 12], [28, 4, 15, 0, 0], [26, 4, 13, 1, 14], [26, 4, 14, 2, 15], [24, 4, 12, 4, 13], [28, 6, 15, 2, 16]],
};
const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50]];
const LEVEL_BITS: Record<QrLevel, number> = { L: 1, M: 0, Q: 3, H: 2 };
/* Galois field 256 with the polynomial 0x11D */
const EXP = new Array<number>(512);
const LOG = new Array<number>(256);
{
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
}
const mul = (a: number, b: number) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
/** Reed Solomon error correction codewords for one block */
function rsEncode(data: number[], ecLen: number) {
// Generator polynomial (x - a^0)(x - a^1)…, highest degree first, leading 1 left out
let gen = [1];
for (let i = 0; i < ecLen; i++) {
const next = new Array<number>(gen.length + 1).fill(0);
for (let j = 0; j < gen.length; j++) {
next[j] ^= gen[j];
next[j + 1] ^= mul(gen[j], EXP[i]);
}
gen = next;
}
const res = new Array<number>(ecLen).fill(0);
for (const d of data) {
const factor = d ^ res[0];
res.shift();
res.push(0);
for (let j = 0; j < ecLen; j++) res[j] ^= mul(gen[j + 1], factor);
}
return res;
}
const capacity = (v: number, l: QrLevel) => {
const [, b1, d1, b2, d2] = EC[l][v - 1];
return b1 * d1 + b2 * d2;
};
/** Encodes text (UTF-8) into a square of modules, true is dark. Picks the smallest version that fits. */
export function encodeQr(text: string, level: QrLevel = 'M'): boolean[][] {
const bytes = Array.from(new TextEncoder().encode(text));
let version = 0;
for (let v = 1; v <= 10; v++) {
const bits = 4 + (v < 10 ? 8 : 16) + bytes.length * 8;
if (Math.ceil(bits / 8) <= capacity(v, level)) {
version = v;
break;
}
}
if (!version) throw new Error('Text too long for a QR code up to version 10');
const v = version;
const size = 17 + 4 * v;
// 1. Data bits
const bits: number[] = [];
const put = (val: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((val >>> i) & 1);
};
put(0b0100, 4);
put(bytes.length, v < 10 ? 8 : 16);
bytes.forEach((b) => put(b, 8));
const cap = capacity(v, level) * 8;
put(0, Math.min(4, cap - bits.length));
while (bits.length % 8) bits.push(0);
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(''), 2));
for (let pad = 0xec; data.length < cap / 8; pad = pad === 0xec ? 0x11 : 0xec) data.push(pad);
// 2. Blocks with error correction, interleaved
const [ecLen, b1, d1, b2, d2] = EC[level][v - 1];
const blocks: { d: number[]; e: number[] }[] = [];
let k = 0;
for (let i = 0; i < b1 + b2; i++) {
const n = i < b1 ? d1 : d2;
const d = data.slice(k, k + n);
k += n;
blocks.push({ d, e: rsEncode(d, ecLen) });
}
const code: number[] = [];
for (let i = 0; i < Math.max(d1, d2); i++) for (const b of blocks) if (i < b.d.length) code.push(b.d[i]);
for (let i = 0; i < ecLen; i++) for (const b of blocks) code.push(b.e[i]);
// 3. Function patterns
const m: (boolean | null)[][] = Array.from({ length: size }, () => new Array<boolean | null>(size).fill(null));
const fixed: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false));
const set = (x: number, y: number, dark: boolean) => {
m[y][x] = dark;
fixed[y][x] = true;
};
const finder = (cx: number, cy: number) => {
for (let dy = -4; dy <= 4; dy++)
for (let dx = -4; dx <= 4; dx++) {
const x = cx + dx;
const y = cy + dy;
if (x < 0 || y < 0 || x >= size || y >= size) continue;
const d = Math.max(Math.abs(dx), Math.abs(dy));
set(x, y, d !== 2 && d !== 4);
}
};
finder(3, 3);
finder(size - 4, 3);
finder(3, size - 4);
for (let i = 0; i < size; i++) {
if (!fixed[6][i]) set(i, 6, i % 2 === 0);
if (!fixed[i][6]) set(6, i, i % 2 === 0);
}
const al = ALIGN[v - 1];
for (const ay of al)
for (const ax of al) {
if ((ax === 6 && ay === 6) || (ax === 6 && ay === size - 7) || (ax === size - 7 && ay === 6)) continue;
for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
}
// Reserve format areas (filled later) and the dark module
for (let i = 0; i < 9; i++) {
if (!fixed[8][i]) set(i, 8, false);
if (!fixed[i][8]) set(8, i, false);
}
for (let i = 0; i < 8; i++) {
set(size - 1 - i, 8, false);
set(8, size - 1 - i, false);
}
set(8, size - 8, true);
// Version information from version 7
if (v >= 7) {
let rem = v;
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
const vbits = (v << 12) | rem;
for (let i = 0; i < 18; i++) {
const bit = ((vbits >>> i) & 1) === 1;
const a = size - 11 + (i % 3);
const b = Math.floor(i / 3);
set(a, b, bit);
set(b, a, bit);
}
}
// 4. Data placement in the zigzag, right to left in pairs of columns
let bitIndex = 0;
const total = code.length * 8;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5;
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j;
const upward = ((right + 1) & 2) === 0;
const y = upward ? size - 1 - vert : vert;
if (fixed[y][x]) continue;
const bit = bitIndex < total ? ((code[bitIndex >>> 3] >>> (7 - (bitIndex & 7))) & 1) === 1 : false;
m[y][x] = bit;
bitIndex++;
}
}
}
// 5. Masks: try all eight, keep the one with the lowest penalty
const MASKS = [
(x: number, y: number) => (x + y) % 2 === 0,
(_x: number, y: number) => y % 2 === 0,
(x: number) => x % 3 === 0,
(x: number, y: number) => (x + y) % 3 === 0,
(x: number, y: number) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
(x: number, y: number) => ((x * y) % 2) + ((x * y) % 3) === 0,
(x: number, y: number) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
(x: number, y: number) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
];
const formatBits = (mask: number) => {
const d = (LEVEL_BITS[level] << 3) | mask;
let rem = d;
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
return ((d << 10) | rem) ^ 0x5412;
};
const withMask = (mask: number) => {
const g = m.map((row) => row.map((c) => Boolean(c)));
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fixed[y][x] && MASKS[mask](x, y)) g[y][x] = !g[y][x];
const f = formatBits(mask);
const bit = (i: number) => ((f >>> i) & 1) === 1;
for (let i = 0; i <= 5; i++) g[i][8] = bit(i);
g[7][8] = bit(6);
g[8][8] = bit(7);
g[8][7] = bit(8);
for (let i = 9; i < 15; i++) g[8][14 - i] = bit(i);
for (let i = 0; i < 8; i++) g[8][size - 1 - i] = bit(i);
for (let i = 8; i < 15; i++) g[size - 15 + i][8] = bit(i);
g[size - 8][8] = true;
return g;
};
const penalty = (g: boolean[][]) => {
let p = 0;
const lines = [...g, ...g[0].map((_, x) => g.map((r) => r[x]))];
for (const line of lines) {
let run = 1;
for (let i = 1; i <= line.length; i++) {
if (i < line.length && line[i] === line[i - 1]) run++;
else {
if (run >= 5) p += 3 + run - 5;
run = 1;
}
}
const s = line.map((c) => (c ? 1 : 0)).join('');
p += 40 * ((s.match(/(?=10111010000)/g) ?? []).length + (s.match(/(?=00001011101)/g) ?? []).length);
}
for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) if (g[y][x] === g[y][x + 1] && g[y][x] === g[y + 1][x] && g[y][x] === g[y + 1][x + 1]) p += 3;
const dark = g.flat().filter(Boolean).length;
p += Math.floor(Math.abs((dark * 100) / (size * size) - 50) / 5) * 10;
return p;
};
let best = withMask(0);
let bestP = penalty(best);
for (let mask = 1; mask < 8; mask++) {
const g = withMask(mask);
const pp = penalty(g);
if (pp < bestP) {
best = g;
bestP = pp;
}
}
return best;
}
/* ------------------------------------------------------------------ */
/* Drawing */
/* ------------------------------------------------------------------ */
const TEXTS = {
en: {
qr: 'QR code', scan: 'Scan with your phone camera', svg: 'Download SVG', png: 'Download PNG', copy: 'Copy link', copied: 'Copied', network: 'Network', password: 'Password', open: 'Open network, no password',
content: 'Link or text', level: 'Error correction', levels: 'From L to H the correction grows. A higher level survives more damage and makes the code denser.', tooLong: 'This text is too long for a code. Shorten the link.',
copyPassword: 'Copy password', showPassword: 'Show password', hidePassword: 'Hide password', bytes: 'bytes',
},
de: {
qr: 'QR-Code', scan: 'Mit der Handykamera scannen', svg: 'Als SVG sichern', png: 'Als PNG sichern', copy: 'Link kopieren', copied: 'Kopiert', network: 'Netzwerk', password: 'Passwort', open: 'Offenes Netz, ohne Passwort',
content: 'Link oder Text', level: 'Fehlerkorrektur', levels: 'Von L bis H wächst die Fehlerkorrektur. Eine höhere Stufe übersteht mehr Schäden und macht den Code dichter.', tooLong: 'Dieser Text ist zu lang für einen Code. Kürz den Link.',
copyPassword: 'Passwort kopieren', showPassword: 'Passwort zeigen', hidePassword: 'Passwort verbergen', bytes: 'Byte',
},
};
type Texts = (typeof TEXTS)['en'];
const QUIET = 4;
const inFinder = (x: number, y: number, n: number) => (x < 7 && y < 7) || (x >= n - 7 && y < 7) || (x < 7 && y >= n - 7);
/** Path data for square modules: runs of dark modules in a row become one rectangle */
function squarePath(m: boolean[][]) {
const n = m.length;
let d = '';
for (let y = 0; y < n; y++) {
let x = 0;
while (x < n) {
if (!m[y][x]) {
x++;
continue;
}
let w = 1;
while (x + w < n && m[y][x + w]) w++;
d += `M${x + QUIET} ${y + QUIET}h${w}v1h-${w}z`;
x += w;
}
}
return d;
}
const ring = (x: number, y: number, s: number, r: number) =>
`M${x + r} ${y}h${s - 2 * r}a${r} ${r} 0 0 1 ${r} ${r}v${s - 2 * r}a${r} ${r} 0 0 1 -${r} ${r}h-${s - 2 * r}a${r} ${r} 0 0 1 -${r} -${r}v-${s - 2 * r}a${r} ${r} 0 0 1 ${r} -${r}z`;
/** The three corner eyes, rounded: a ring and a centre */
function Eyes({ n, color }: { n: number; color: string }) {
const at = [[0, 0], [n - 7, 0], [0, n - 7]];
return (
<g fill={color}>
{at.map(([x, y]) => (
<g key={`${x}-${y}`}>
<path fillRule="evenodd" d={ring(x + QUIET, y + QUIET, 7, 2.2) + ring(x + QUIET + 1, y + QUIET + 1, 5, 1.6)} />
<rect x={x + QUIET + 2} y={y + QUIET + 2} width="3" height="3" rx={0.9} />
</g>
))}
</g>
);
}
export interface QrGraphicProps {
value: string;
level?: QrLevel;
/** dots: round modules and rounded eyes */
shape?: 'square' | 'dots';
/** Module color. Keep it dark: scanners need contrast to the light tile. */
color?: string;
/** A picture in the middle, e.g. a logo. Raises the error correction to H. */
logo?: React.ReactNode;
/** Reveal the modules from the centre when scrolled into view */
reveal?: boolean;
label?: string;
className?: string;
}
/** The code as SVG on a light tile, with quiet zone. Same on server and client. */
export function QrGraphic({ value, level = 'M', shape = 'square', color = '#111318', logo, reveal = true, label, className }: QrGraphicProps) {
const lvl = logo ? 'H' : level;
const m = useMemo(() => {
try {
return encodeQr(value, lvl);
} catch {
return null;
}
}, [value, lvl]);
const [ref, inView] = useInView<HTMLDivElement>();
if (!m) return null;
const n = m.length;
const box = n + QUIET * 2;
// The logo hole: about a fifth of the code, odd so it sits in the middle, the modules under it are left out
const hole = logo ? Math.round(n * 0.22) | 1 : 0;
const h0 = Math.floor((n - hole) / 2);
const inHole = (x: number, y: number) => Boolean(logo) && x >= h0 - 1 && x < h0 + hole + 1 && y >= h0 - 1 && y < h0 + hole + 1;
const masked = logo ? m.map((row, y) => row.map((c, x) => c && !inHole(x, y))) : m;
const dots: React.ReactNode[] = [];
if (shape === 'dots') for (let y = 0; y < n; y++) for (let x = 0; x < n; x++) if (masked[y][x] && !inFinder(x, y, n)) dots.push(<circle key={`${x}.${y}`} cx={x + QUIET + 0.5} cy={y + QUIET + 0.5} r={0.46} />);
return (
<div ref={ref} data-clip-ok className={cn('relative aspect-square w-full overflow-hidden bg-white', className)}>
<svg
viewBox={`0 0 ${box} ${box}`}
role="img"
aria-label={label ?? value}
shapeRendering={shape === 'square' ? 'crispEdges' : undefined}
// The modules open from the middle the first time the code scrolls into view
className="block size-full transition-[clip-path] duration-[1100ms] ease-[var(--ease-kit)] motion-reduce:transition-none"
style={reveal ? { clipPath: inView ? 'circle(75% at 50% 50%)' : 'circle(0% at 50% 50%)' } : undefined}
>
{shape === 'dots' ? <g fill={color}>{dots}</g> : <path d={squarePath(masked)} fill={color} />}
{shape === 'dots' && <Eyes n={n} color={color} />}
</svg>
{logo && (
<div className="absolute left-1/2 top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center overflow-hidden rounded-[22%] bg-white" style={{ width: `${(hole / box) * 100}%`, height: `${(hole / box) * 100}%` }}>
{logo}
</div>
)}
</div>
);
}
/** SVG markup of a code, for downloads */
export function qrSvgString(value: string, level: QrLevel = 'M', color = '#111318') {
const m = encodeQr(value, level);
const box = m.length + QUIET * 2;
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${box} ${box}" shape-rendering="crispEdges"><rect width="${box}" height="${box}" fill="#fff"/><path d="${squarePath(m)}" fill="${color}"/></svg>`;
}
function download(name: string, svg: string, kind: 'svg' | 'png', px = 1024) {
if (!svg) return;
const url = URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }));
const save = (href: string, ext: string) => {
const a = document.createElement('a');
a.href = href;
a.download = `${name}.${ext}`;
a.click();
};
if (kind === 'svg') {
save(url, 'svg');
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return;
}
const img = new Image();
img.onload = () => {
const c = document.createElement('canvas');
c.width = c.height = px;
const ctx = c.getContext('2d');
if (!ctx) return;
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, 0, 0, px, px);
save(c.toDataURL('image/png'), 'png');
URL.revokeObjectURL(url);
};
img.src = url;
}
/** Wi-Fi network as the text phones understand */
export function wifiString({ ssid, password, security = 'WPA', hidden }: { ssid: string; password?: string; security?: 'WPA' | 'WEP' | 'nopass'; hidden?: boolean }) {
const esc = (s: string) => s.replace(/([\\;,:"])/g, '\\$1');
return `WIFI:T:${security};S:${esc(ssid)};${security !== 'nopass' && password ? `P:${esc(password)};` : ''}${hidden ? 'H:true;' : ''};`;
}
/* ------------------------------------------------------------------ */
/* QrCode */
/* ------------------------------------------------------------------ */
export type QrCodeVariant = 'basic' | 'styled' | 'card' | 'wifi' | 'generator';
export interface QrCodeProps {
variant?: QrCodeVariant;
/** The text or link in the code */
value?: string;
level?: QrLevel;
/** A short title next to or above the code */
title?: string;
/** A line under the title */
description?: string;
/** card: the address printed under the code, short and readable */
displayUrl?: string;
/** styled: a picture in the middle */
logo?: React.ReactNode;
/** styled: module color, keep it dark */
color?: string;
/** wifi: the network */
ssid?: string;
password?: string;
security?: 'WPA' | 'WEP' | 'nopass';
/** File name of downloads, without extension */
fileName?: string;
labels?: Partial<Texts>;
className?: string;
}
const BTN =
'inline-flex h-11 items-center justify-center gap-1.5 rounded-lg px-2 sm:px-3.5 text-[13px] font-medium outline-none transition-[background-color,color,scale] duration-150 focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_30%,transparent)] active:scale-[0.97] sm:h-9';
const OUTLINE = 'border border-border-strong bg-background text-foreground hover:bg-accent';
/**
* A scannable QR code, drawn by the kit's own encoder (byte mode, versions 1 to 10).
* <QrCode value="https://cafe-mira.example/menu" title="Our menu" />
* <QrCode variant="styled" value="…" logo={<Mark />} />
* <QrCode variant="card" value="…" displayUrl="cafe-mira.example/menu" title="Scan for the menu" />
* <QrCode variant="wifi" ssid="Café Mira Guests" password="…" />
* <QrCode variant="generator" value="https://…" />
*/
export function QrCode({ variant = 'basic', value = '', level = 'M', title, description, displayUrl, logo, color, ssid = '', password = '', security = 'WPA', fileName = 'qr-code', labels, className }: QrCodeProps) {
const t = useTexts(TEXTS, labels);
const [copyState, copy] = useCopy();
const [pwState, copyPw] = useCopy();
const [text, setText] = useState(value);
const [lvl, setLvl] = useState<QrLevel>(level);
const [showPw, setShowPw] = useState(false);
const inputId = useId();
const svgFor = (v: string, l: QrLevel) => {
try {
return qrSvgString(v, l, variant === 'styled' ? color : undefined);
} catch {
return '';
}
};
const downloads = (v: string, l: QrLevel) => (
<>
<button type="button" onClick={() => download(fileName, svgFor(v, l), 'svg')} className={cn(BTN, OUTLINE)}>
<Icon name="download" size={15} />
{t.svg}
</button>
<button type="button" onClick={() => download(fileName, svgFor(v, l), 'png')} className={cn(BTN, OUTLINE)}>
<Icon name="download" size={15} />
{t.png}
</button>
</>
);
/* ---------- generator: type, see, download ---------- */
if (variant === 'generator') {
let fits = true;
try {
encodeQr(text || ' ', lvl);
} catch {
fits = false;
}
const levels: QrLevel[] = ['L', 'M', 'Q', 'H'];
return (
<div className={cn('grid w-full grid-cols-[minmax(0,1fr)] gap-6 sm:grid-cols-[minmax(0,1fr)_14rem] sm:items-start', className)}>
<div className="grid gap-5">
<div className="grid gap-2">
<label htmlFor={inputId} className="text-sm font-medium text-foreground">{title ?? t.content}</label>
<textarea
id={inputId}
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
aria-invalid={!fits || undefined}
className={cn('min-h-24 w-full resize-y rounded-lg border bg-background px-3.5 py-2.5 text-base text-foreground shadow-[var(--shadow-sm)] outline-none transition-[border-color,box-shadow] duration-200 placeholder:text-subtle-foreground focus:border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] focus:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent)] sm:text-sm', fits ? 'border-input' : 'border-destructive')}
/>
<p className={cn('text-[12.5px]', fits ? 'tabular-nums text-muted-foreground' : 'font-medium text-destructive')} aria-live="polite">
{fits ? `${new TextEncoder().encode(text).length} ${t.bytes}` : t.tooLong}
</p>
</div>
<div className="grid gap-2">
<p id={`${inputId}-lvl`} className="text-sm font-medium text-foreground">{t.level}</p>
<div
role="radiogroup"
aria-labelledby={`${inputId}-lvl`}
onKeyDown={(e) => {
// Radio group after the APG: arrows move and choose, one tab stop
const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!d) return;
e.preventDefault();
const next = levels[(levels.indexOf(lvl) + d + levels.length) % levels.length];
setLvl(next);
const group = e.currentTarget;
requestAnimationFrame(() => group.querySelector<HTMLElement>(`[data-level="${next}"]`)?.focus());
}}
className="inline-flex h-12 w-fit rounded-lg bg-muted p-0.5 sm:h-9"
>
{levels.map((l) => (
<button key={l} type="button" role="radio" data-level={l} tabIndex={lvl === l ? 0 : -1} aria-checked={lvl === l} onClick={() => setLvl(l)} className={cn('w-12 rounded-[7px] text-[13px] font-medium outline-none transition-[background-color,color,box-shadow] duration-200 focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', lvl === l ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{l}
</button>
))}
</div>
<p className="text-[12.5px] text-muted-foreground">{t.levels}</p>
</div>
{fits && <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">{downloads(text || ' ', lvl)}</div>}
</div>
<div className="mx-auto w-full max-w-[14rem] rounded-xl bg-white p-2.5 shadow-[var(--highlight),var(--shadow-md)] ring-1 ring-border">
{fits ? <QrGraphic value={text || ' '} level={lvl} label={t.qr} reveal={false} className="rounded-md" /> : <div className="grid aspect-square place-items-center text-[#5b6170]"><Icon name="qr-code" size={40} /></div>}
</div>
</div>
);
}
/* ---------- wifi: a guest network card ---------- */
if (variant === 'wifi') {
const v = wifiString({ ssid, password, security });
return (
<div className={cn('grid w-full grid-cols-[minmax(0,1fr)] items-center gap-6 rounded-2xl border border-border bg-card p-5 shadow-[var(--highlight),var(--shadow-md)] sm:grid-cols-[11rem_minmax(0,1fr)] sm:p-6', className)}>
<div className="mx-auto w-full max-w-[11rem] rounded-xl bg-white p-2 shadow-[var(--shadow-sm)] ring-1 ring-border">
<QrGraphic value={v} level={level} label={`${t.qr}: ${ssid}`} className="rounded-lg" />
</div>
<div className="min-w-0">
{title && <h3 className="text-[15px] font-semibold text-foreground">{title}</h3>}
{description && <p className="mt-0.5 text-[13px] text-muted-foreground">{description}</p>}
<dl className="mt-4 grid gap-3 text-sm">
<div>
<dt className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
<Icon name="wifi" size={13} />
{t.network}
</dt>
<dd className="mt-0.5 font-medium text-foreground">{ssid}</dd>
</div>
{security !== 'nopass' ? (
<div>
<dt className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
<Icon name="lock" size={13} />
{t.password}
</dt>
<dd className="-my-1 flex items-center gap-1">
<span className="min-w-0 truncate font-medium tabular-nums text-foreground">{showPw ? password : '•'.repeat(Math.min(12, password.length))}</span>
<button type="button" aria-label={showPw ? t.hidePassword : t.showPassword} aria-pressed={showPw} onClick={() => setShowPw(!showPw)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:size-8">
<Icon name={showPw ? 'eye-off' : 'eye'} size={15} />
</button>
<button type="button" aria-label={pwState === 'copied' ? t.copied : t.copyPassword} onClick={() => copyPw(password)} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:size-8">
<Icon name={pwState === 'copied' ? 'check' : 'copy'} size={15} className={cn(pwState === 'copied' && 'text-success')} />
</button>
</dd>
</div>
) : (
<p className="text-[13px] text-muted-foreground">{t.open}</p>
)}
</dl>
<p className="mt-4 flex items-center gap-1.5 text-[12.5px] text-subtle-foreground">
<Icon name="scan" size={14} />
{t.scan}
</p>
</div>
</div>
);
}
/* ---------- card: a table tent ---------- */
if (variant === 'card') {
return (
<div className={cn('mx-auto w-full max-w-[19rem] overflow-hidden rounded-2xl border border-border bg-card text-center shadow-[var(--highlight),var(--shadow-lg)]', className)}>
<div className="px-6 pb-2 pt-6">
{title && <h3 className="text-balance text-lg font-semibold tracking-[-0.01em] text-foreground">{title}</h3>}
{description && <p className="mt-1 text-[13px] text-muted-foreground">{description}</p>}
</div>
<div data-clip-ok className="relative mx-6 my-4 overflow-hidden rounded-xl bg-white p-2.5 shadow-[var(--shadow-sm)] ring-1 ring-border">
<QrGraphic value={value} level={level} label={title ?? t.qr} className="rounded-lg" />
{/* A scan line passes over the code, quietly, as in a phone camera */}
<span aria-hidden className="kitfx-qr-scan pointer-events-none absolute inset-x-0 top-0 h-12 bg-[linear-gradient(to_bottom,transparent,color-mix(in_oklab,var(--primary)_30%,transparent),transparent)] motion-reduce:hidden" />
<style href="kitfx-qr" precedence="default">{'@keyframes kitfx-qr-scan{0%,10%{top:-3rem;opacity:0}22%{opacity:1}78%{opacity:1}90%,100%{top:100%;opacity:0}}.kitfx-qr-scan{animation:kitfx-qr-scan 3.8s var(--ease-kit) infinite}'}</style>
</div>
<div className="border-t border-border px-6 py-4">
<p className="text-[12px] text-subtle-foreground">{t.scan}</p>
{displayUrl && <p className="mt-1 truncate text-[14px] font-semibold tracking-[-0.01em] text-foreground">{displayUrl}</p>}
<button type="button" onClick={() => copy(value)} className={cn(BTN, OUTLINE, 'mt-3 w-full')}>
<Icon name={copyState === 'copied' ? 'check' : 'link'} size={15} className={cn(copyState === 'copied' && 'text-success')} />
{copyState === 'copied' ? t.copied : t.copy}
</button>
</div>
</div>
);
}
/* ---------- styled and basic ---------- */
const styled = variant === 'styled';
return (
<div className={cn('grid w-full grid-cols-[minmax(0,1fr)] items-center gap-6 sm:grid-cols-[13rem_minmax(0,1fr)]', className)}>
<div className="mx-auto w-full max-w-[13rem] rounded-2xl bg-white p-3 shadow-[var(--highlight),var(--shadow-md)] ring-1 ring-border">
<QrGraphic value={value} level={level} shape={styled ? 'dots' : 'square'} color={color} logo={styled ? logo : undefined} label={title ?? t.qr} className="rounded-lg" />
</div>
<div className="min-w-0 max-sm:text-center">
{title && <h3 className="text-lg font-semibold tracking-[-0.01em] text-foreground">{title}</h3>}
{description && <p className="mt-1 text-[14px] leading-relaxed text-muted-foreground">{description}</p>}
{/* Phones get the two downloads side by side and the link below, larger screens one row */}
<div className="mt-4 grid grid-cols-2 gap-2 sm:flex sm:flex-wrap">
{downloads(value, logo && styled ? 'H' : level)}
<button type="button" onClick={() => copy(value)} className={cn(BTN, 'col-span-2 text-muted-foreground hover:bg-accent hover:text-foreground')}>
<Icon name={copyState === 'copied' ? 'check' : 'link'} size={15} className={cn(copyState === 'copied' && 'text-success')} />
{copyState === 'copied' ? t.copied : t.copy}
</button>
</div>
</div>
</div>
);
}Frequent questions
How do I install QR code?
With aiw add @ai-website/qr-code. The command puts components/ui/qr-code.tsx into your project, the code is yours from then on. It brings along icon and copy-button. It needs no further npm packages.
Which variants does QR code have?
5 variants, Plain, Styled, Table card, Wi-Fi card and Generator. You pick one with the variant prop, for example variant="card".
Can I translate the texts of QR code?
Yes. QR code carries 17 texts of its own in English and German, such as “QR code”, “Scan with your phone camera” and “Download SVG”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with QR code?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find QR code with aiw_components and install it with aiw_add @ai-website/qr-code. Name the variant in your prompt, for example variant="styled", and the assistant writes your content straight into the props.
Can I adjust QR code without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching QrCode code to copy.