QR-Code
Echte, scanbare QR-Codes aus dem eigenen Encoder des Kits. Schlicht mit SVG- und PNG-Download, gestaltet mit Punkten und Logo, als Tischkarte, als WLAN-Karte oder als Generator zum Tippen.
Eine von 5 Components in Media
Variants
Details
Was QR-Code mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Media
- Bereich
- Elements
- Variants
- 5
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/qr-code.tsx
- Bringt mit
- utilskiticoncopy-button
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 17 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle QrCodeProps aus components/ui/qr-code.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
Eingebaute Texte
Diese Texte schreibt QR-Code selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 17 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| qr | Englisch QR code | Deutsch QR-Code |
| scan | Englisch Scan with your phone camera | Deutsch Mit der Handykamera scannen |
| svg | Englisch Download SVG | Deutsch Als SVG sichern |
| png | Englisch Download PNG | Deutsch Als PNG sichern |
| copy | Englisch Copy link | Deutsch Link kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| network | Englisch Network | Deutsch Netzwerk |
| password | Englisch Password | Deutsch Passwort |
| open | Englisch Open network, no password | Deutsch Offenes Netz, ohne Passwort |
| content | Englisch Link or text | Deutsch Link oder Text |
| level | Englisch Error correction | Deutsch Fehlerkorrektur |
| levels | Englisch From L to H the correction grows. A higher level survives more damage and makes the code denser. | Deutsch Von L bis H wächst die Fehlerkorrektur. Eine höhere Stufe übersteht mehr Schäden und macht den Code dichter. |
| tooLong | Englisch This text is too long for a code. Shorten the link. | Deutsch Dieser Text ist zu lang für einen Code. Kürz den Link. |
| copyPassword | Englisch Copy password | Deutsch Passwort kopieren |
| showPassword | Englisch Show password | Deutsch Passwort zeigen |
| hidePassword | Englisch Hide password | Deutsch Passwort verbergen |
| bytes | Englisch bytes | Deutsch Byte |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- QR-Code reagiert selbst auf die Tasten Leertaste.
- Setzt aria-checked, aria-invalid, aria-label, aria-labelledby, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen img, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 639 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich QR-Code?
Mit aiw add @ai-website/qr-code. Der Befehl legt die Datei components/ui/qr-code.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und copy-button. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat QR-Code?
5 Varianten, Schlicht, Gestaltet, Tischkarte, WLAN-Karte und Generator. Du wählst sie mit der Prop variant, zum Beispiel variant="card".
Kann ich die Texte von QR-Code übersetzen?
Ja. QR-Code bringt 17 eigene Texte auf Deutsch und Englisch mit, etwa „QR-Code“, „Mit der Handykamera scannen“ und „Als SVG sichern“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent QR-Code einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini QR-Code mit aiw_components und installieren sie mit aiw_add @ai-website/qr-code. Nenn im Prompt die Variante, etwa variant="styled", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich QR-Code ohne Code anpassen?
Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden QrCode-Code zum Kopieren.