Skip to content
UI Kit
Accent color

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

5 variants

Variants

Plain

A crisp square code on a light tile, always dark on light so phones read it in either mode. Downloads as SVG for print or PNG for the web, the modules open from the middle as it scrolls in.

Open in Studio

Styled

Round modules, rounded eyes and a logo in the middle. The logo raises the error correction to the highest level, so the code still scans with its middle covered.

Open in Studio

Table card

A card for tables and counters with a short address to type by hand. A soft scan line passes over the code, still with reduced motion.

Open in Studio

Wi-Fi card

Guests scan and join the network, no typing. The password is hidden until asked for and copies with one tap.

Open in Studio

Generator

Type a link and the code redraws as you type, with the size in bytes, a choice of error correction and both downloads. Too long a text is named, not cut.

Open in Studio

Details

What QR code brings along, read from the registry and the source.

Category
Media
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

Props

The QrCodeProps interface from components/ui/qr-code.tsx, with the comments from the code.

  • 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

Built-in texts

QR code writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 17 texts
qrEnglish QR codeGerman QR-Code
scanEnglish Scan with your phone cameraGerman Mit der Handykamera scannen
svgEnglish Download SVGGerman Als SVG sichern
pngEnglish Download PNGGerman Als PNG sichern
copyEnglish Copy linkGerman Link kopieren
copiedEnglish CopiedGerman Kopiert
networkEnglish NetworkGerman Netzwerk
passwordEnglish PasswordGerman Passwort
openEnglish Open network, no passwordGerman Offenes Netz, ohne Passwort
contentEnglish Link or textGerman Link oder Text
levelEnglish Error correctionGerman Fehlerkorrektur
levelsEnglish 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.
tooLongEnglish 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.
copyPasswordEnglish Copy passwordGerman Passwort kopieren
showPasswordEnglish Show passwordGerman Passwort zeigen
hidePasswordEnglish Hide passwordGerman Passwort verbergen
bytesEnglish bytesGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard