Zum Inhalt springen
UI Kit
Accent

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

5 Variants

Variants

Schlicht

Ein scharfer, eckiger Code auf heller Fläche, immer dunkel auf hell, damit Telefone ihn in jedem Modus lesen. Download als SVG für den Druck oder PNG fürs Web, die Module öffnen sich beim Hereinscrollen aus der Mitte.

Im Studio öffnen

Gestaltet

Runde Module, abgerundete Augen und ein Logo in der Mitte. Das Logo hebt die Fehlerkorrektur auf die höchste Stufe, damit der Code trotz verdeckter Mitte lesbar bleibt.

Im Studio öffnen

Tischkarte

Eine Karte für Tische und Theken mit kurzer Adresse zum Abtippen. Eine sanfte Scanlinie wandert über den Code, bei reduzierter Bewegung steht sie still.

Im Studio öffnen

WLAN-Karte

Gäste scannen und sind im Netz, ohne zu tippen. Das Passwort bleibt verborgen, bis man es zeigt, und kopiert sich mit einem Tipp.

Im Studio öffnen

Generator

Einen Link tippen, und der Code zeichnet sich beim Tippen neu, mit Größe in Byte, Wahl der Fehlerkorrektur und beiden Downloads. Zu langer Text wird benannt, nicht abgeschnitten.

Im Studio öffnen

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

Props

Die Schnittstelle QrCodeProps aus components/ui/qr-code.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    QrCodeVariant

    basicstyledcardwifigenerator

  • valueoptional
    string

    The text or link in the code

  • leveloptional
    QrLevel

    LMQH

  • titleoptional
    string

    A short title next to or above the code

  • descriptionoptional
    string

    A line under the title

  • displayUrloptional
    string

    card: the address printed under the code, short and readable

  • logooptional
    React.ReactNode

    styled: a picture in the middle

  • coloroptional
    string

    styled: module color, keep it dark

  • ssidoptional
    string

    wifi: the network

  • passwordoptional
    string
  • securityoptional
    'WPA' | 'WEP' | 'nopass'
  • fileNameoptional
    string

    File 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
qrEnglisch QR codeDeutsch QR-Code
scanEnglisch Scan with your phone cameraDeutsch Mit der Handykamera scannen
svgEnglisch Download SVGDeutsch Als SVG sichern
pngEnglisch Download PNGDeutsch Als PNG sichern
copyEnglisch Copy linkDeutsch Link kopieren
copiedEnglisch CopiedDeutsch Kopiert
networkEnglisch NetworkDeutsch Netzwerk
passwordEnglisch PasswordDeutsch Passwort
openEnglisch Open network, no passwordDeutsch Offenes Netz, ohne Passwort
contentEnglisch Link or textDeutsch Link oder Text
levelEnglisch Error correctionDeutsch Fehlerkorrektur
levelsEnglisch 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.
tooLongEnglisch 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.
copyPasswordEnglisch Copy passwordDeutsch Passwort kopieren
showPasswordEnglisch Show passwordDeutsch Passwort zeigen
hidePasswordEnglisch Hide passwordDeutsch Passwort verbergen
bytesEnglisch bytesDeutsch 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
components/ui/qr-code.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert