import React, { useEffect, useMemo, useRef, useState } from "react"; import qrcode from "qrcode-generator"; const DEFAULT_CONTENT = "https://example.com/"; const DEFAULT_FG = "#212529"; const DEFAULT_BG = "#ffffff"; const MODULE_STYLES = [ { id: "square", label: "Square", hint: "classic" }, { id: "rounded", label: "Rounded", hint: "soft corners" }, { id: "circle", label: "Circle", hint: "pill" }, { id: "diamond", label: "Diamond", hint: "sparkly" }, { id: "metaballs", label: "Metaballs", hint: "melted" } ]; const ECC_LEVELS = [ { id: "L", label: "L (7%)" }, { id: "M", label: "M (15%)" }, { id: "Q", label: "Q (25%)" }, { id: "H", label: "H (30%)" } ]; const QUIET_ZONE_MODULES = 4; const isFinderModule = (row, col, count) => (row < 7 && col < 7) || (row < 7 && col >= count - 7) || (row >= count - 7 && col < 7); const roundedCornerPath = (x, y, s, r) => `M${x + r} ${y}H${x + s - r}Q${x + s} ${y} ${x + s} ${y + r}V${y + s - r}Q${x + s} ${y + s} ${x + s - r} ${y + s}H${x + r}Q${x} ${y + s} ${x} ${y + s - r}V${y + r}Q${x} ${y} ${x + r} ${y}Z`; const circlePath = (x, y, s, r) => { const cx = x + s / 2; const cy = y + s / 2; return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0Z`; }; const METABALL_INFLUENCE = 1.15; const METABALL_THRESHOLD = 0.52; const METABALL_SUBDIV = 12; const METABALL_ICON_RADIUS = 0.66; const modulePath = (style, x, y, s) => { switch (style) { case "circle": return circlePath(x, y, s, s * 0.5); case "metaballs": return circlePath(x, y, s, s * METABALL_ICON_RADIUS); case "diamond": return `M${x + s / 2} ${y}L${x + s} ${y + s / 2}L${x + s / 2} ${y + s}L${x} ${y + s / 2}Z`; case "rounded": return roundedCornerPath(x, y, s, s * 0.2); default: return `M${x} ${y}H${x + s}V${y + s}H${x}Z`; } }; const marchingSquaresPath = (balls, total) => { const R = METABALL_INFLUENCE; const T = METABALL_THRESHOLD; const S = Math.min(METABALL_SUBDIV, Math.max(6, Math.round(450 / total))); const R2 = R * R; const N = total * S; const grid = new Float32Array(N * N); const sorted = balls.slice().sort((a, b) => a.x - b.x); for (let j = 0; j < N; j += 1) { const py = j / S; let lo = 0; let hi = 0; for (let i = 0; i < N; i += 1) { const px = i / S; while (hi < sorted.length && sorted[hi].x <= px + R) hi += 1; while (lo < hi && sorted[lo].x < px - R) lo += 1; let w = 0; for (let b = lo; b < hi; b += 1) { const ball = sorted[b]; const dy = py - ball.y; if (dy > R || dy < -R) continue; const dx = px - ball.x; const d2 = dx * dx + dy * dy; if (d2 < R2) { const fall = 1 - d2 / R2; w += fall * fall; } } grid[j * N + i] = w; } } const mask = new Uint8Array(N * N); for (let p = 0; p < N * N; p += 1) mask[p] = grid[p] > T ? 1 : 0; const label = new Int32Array(N * N); let compCount = 0; { const stack = new Int32Array(N * N); for (let start = 0; start < N * N; start += 1) { if (label[start] !== 0 || !mask[start]) continue; compCount += 1; let sp = 0; stack[sp++] = start; label[start] = compCount; while (sp > 0) { const p = stack[--sp]; const i = p % N; const j = (p / N) | 0; if (i > 0 && label[p - 1] === 0 && mask[p - 1]) { label[p - 1] = compCount; stack[sp++] = p - 1; } if (i + 1 < N && label[p + 1] === 0 && mask[p + 1]) { label[p + 1] = compCount; stack[sp++] = p + 1; } if (j > 0 && label[p - N] === 0 && mask[p - N]) { label[p - N] = compCount; stack[sp++] = p - N; } if (j + 1 < N && label[p + N] === 0 && mask[p + N]) { label[p + N] = compCount; stack[sp++] = p + N; } } } } const edgeList = []; const edgeComp = []; for (let j = 0; j < N; j += 1) { for (let i = 0; i < N; i += 1) { const p = j * N + i; if (!mask[p]) continue; const c = label[p]; if (i + 1 >= N || !mask[p + 1]) edgeList.push([i + 1, j, i + 1, j + 1]), edgeComp.push(c); if (i === 0 || !mask[p - 1]) edgeList.push([i, j, i, j + 1]), edgeComp.push(c); if (j + 1 >= N || !mask[p + N]) edgeList.push([i, j + 1, i + 1, j + 1]), edgeComp.push(c); if (j === 0 || !mask[p - N]) edgeList.push([i, j, i + 1, j]), edgeComp.push(c); } } const byCorner = new Map(); const key = (x, y) => `${x},${y}`; edgeList.forEach((e, idx) => { const pushAt = (a, b, side) => { const k = key(a, b); if (!byCorner.has(k)) byCorner.set(k, []); byCorner.get(k).push([idx, side]); }; pushAt(e[0], e[1], 0); pushAt(e[2], e[3], 1); }); const smooth = (pts, iters) => { let p = pts; for (let iter = 0; iter < iters; iter += 1) { const out = []; for (let i = 0; i < p.length; i += 1) { const a = p[i]; const b = p[(i + 1) % p.length]; out.push([a[0] * 0.75 + b[0] * 0.25, a[1] * 0.75 + b[1] * 0.25]); out.push([a[0] * 0.25 + b[0] * 0.75, a[1] * 0.25 + b[1] * 0.75]); } p = out; } return p; }; const contours = []; for (let comp = 1; comp <= compCount; comp += 1) { const compEdges = []; edgeComp.forEach((c, idx) => { if (c === comp) compEdges.push(idx); }); if (compEdges.length === 0) continue; const used = new Uint8Array(compEdges.length); for (let li = 0; li < compEdges.length; li += 1) { if (used[li]) continue; const first = compEdges[li]; const e = edgeList[first]; const loop = [[e[0], e[1]]]; let cxe = e[2]; let cye = e[3]; used[li] = 1; loop.push([cxe, cye]); let guard = 0; for (;;) { const options = byCorner.get(key(cxe, cye)) ?? []; let next = -1; let nextSide = 0; for (const [eIdx, side] of options) { const oi = compEdges.indexOf(eIdx); if (oi !== -1 && !used[oi]) { next = eIdx; nextSide = side; used[oi] = 1; break; } } if (next === -1) break; const os = edgeList[next]; cxe = nextSide === 0 ? os[2] : os[0]; cye = nextSide === 0 ? os[3] : os[1]; loop.push([cxe, cye]); if (key(cxe, cye) === key(loop[0][0], loop[0][1])) break; guard += 1; if (guard > compEdges.length) break; } if (loop.length >= 4) { const pts = loop.map(([x, y]) => [x / S, y / S]); contours.push(smooth(pts, 2)); } } } return contours .map((loop) => { const d = loop .map((p2, i) => `${i === 0 ? "M" : "L"}${p2[0].toFixed(3)} ${p2[1].toFixed(3)}`) .join(""); return `${d}Z`; }) .join(""); }; const buildQR = (content, ecc, style, quietZone) => { const qr = qrcode(0, ecc); qr.addData(content); qr.make(); const count = qr.getModuleCount(); const quiet = quietZone ? QUIET_ZONE_MODULES : 0; const total = count + quiet * 2; const finderParts = []; const cellParts = []; const balls = []; for (let row = 0; row < count; row += 1) { for (let col = 0; col < count; col += 1) { if (!qr.isDark(row, col)) continue; const x = col + quiet; const y = row + quiet; if (isFinderModule(row, col, count)) { finderParts.push(modulePath("square", x, y, 1)); } else if (style === "metaballs") { balls.push({ x: x + 0.5, y: y + 0.5 }); } else { cellParts.push(modulePath(style, x, y, 1)); } } } if (style === "metaballs") { return { total, finderPath: finderParts.join(""), blobPath: marchingSquaresPath(balls, total) }; } return { total, path: finderParts.join("") + cellParts.join("") }; }; const roundedRect = (x, y, width, height, radius) => { const r = Math.min(radius, width / 2, height / 2); return `M${x + r} ${y}H${x + width - r}Q${x + width} ${y} ${x + width} ${y + r}V${y + height - r}Q${x + width} ${y + height} ${x + width - r} ${y + height}H${x + r}Q${x} ${y + height} ${x} ${y + height - r}V${y + r}Q${x} ${y} ${x + r} ${y}Z`; }; const computeLogoRect = (total, logoFrac, logoBorder) => { const logoSize = total * logoFrac; const plateSize = logoSize * (1 + logoBorder * 2); const plateRadius = plateSize * 0.2; const center = total / 2; return { logoSize, plateSize, plateRadius, logoX: center - logoSize / 2, logoY: center - logoSize / 2, plateX: center - plateSize / 2, plateY: center - plateSize / 2 }; }; const drawQR = (canvas, options) => { const { content, ecc, style, fg, bg, quietZone, transparentBg, logo, logoFrac, logoBorder, plateColor } = options; const ctx = canvas.getContext("2d"); const size = canvas.width; if (!transparentBg) { ctx.fillStyle = bg; ctx.fillRect(0, 0, size, size); } const qr = buildQR(content, ecc, style, quietZone); const modulePx = size / qr.total; ctx.save(); ctx.scale(modulePx, modulePx); ctx.fillStyle = fg; if (style === "metaballs") { ctx.fill(new Path2D(qr.finderPath)); ctx.fill(new Path2D(qr.blobPath)); } else { ctx.fill(new Path2D(qr.path)); } ctx.restore(); if (logo && logo.img && logo.img.complete && logoFrac > 0) { ctx.save(); ctx.scale(modulePx, modulePx); const rect = computeLogoRect(qr.total, logoFrac, logoBorder); const platePath = new Path2D( roundedRect(rect.plateX, rect.plateY, rect.plateSize, rect.plateSize, rect.plateRadius) ); ctx.fillStyle = plateColor; ctx.fill(platePath); ctx.clip(platePath); ctx.drawImage(logo.img, rect.logoX, rect.logoY, rect.logoSize, rect.logoSize); ctx.restore(); } }; const buildSVG = (options, size = 1000) => { const { content, ecc, style, fg, bg, quietZone, transparentBg, logo, logoFrac, logoBorder, plateColor } = options; const qr = buildQR(content, ecc, style, quietZone); const { total } = qr; const parts = [ `` ]; if (!transparentBg) { parts.push(``); } if (style === "metaballs") { parts.push( ``, `` ); } else { parts.push(``); } if (logo && logo.src && logoFrac > 0) { const rect = computeLogoRect(total, logoFrac, logoBorder); parts.push( `` ); parts.push( `` ); } parts.push(""); return parts.join(""); }; const readFileAsDataUrl = (file) => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(new Error("Failed to read file.")); reader.readAsDataURL(file); }); const triggerDownload = (href, filename) => { const link = document.createElement("a"); link.href = href; link.download = filename; link.click(); }; const StyleIcon = ({ style, color }) => ( ); export default function QRDesigner({ onHome }) { const [content, setContent] = useState(DEFAULT_CONTENT); const [ecc, setEcc] = useState("H"); const [style, setStyle] = useState("square"); const [fg, setFg] = useState(DEFAULT_FG); const [bg, setBg] = useState(DEFAULT_BG); const [quietZone, setQuietZone] = useState(true); const [transparentBg, setTransparentBg] = useState(false); const [logo, setLogo] = useState(null); const [logoFrac, setLogoFrac] = useState(0.22); const [logoBorder, setLogoBorder] = useState(0.225); const previewCanvasRef = useRef(null); const previewSize = 560; const options = useMemo( () => ({ content, ecc, style, fg, bg, quietZone, transparentBg, logo, logoFrac, logoBorder, plateColor: "#ffffff" }), [content, ecc, style, fg, bg, quietZone, transparentBg, logo, logoFrac, logoBorder] ); const qrState = useMemo(() => { const text = content.trim(); if (!text) return { ok: false, reason: "Enter some text or a URL above." }; try { return { ok: true, data: buildQR(text, ecc, style, quietZone) }; } catch (error) { return { ok: false, reason: "Content is too long for this error correction level." }; } }, [content, ecc, style, quietZone]); useEffect(() => { if (!previewCanvasRef.current) return; const canvas = previewCanvasRef.current; canvas.width = previewSize; canvas.height = previewSize; const text = content.trim(); if (!text) return; try { drawQR(canvas, options); } catch (error) { console.error(error); } }, [options, content]); const handleLogoFile = async (event) => { const file = event.target.files?.[0]; if (!file) return; try { const src = await readFileAsDataUrl(file); const img = new Image(); img.onload = () => setLogo((current) => ({ ...current, img })); img.src = src; setLogo({ src, img }); } catch (error) { console.error(error); } event.target.value = ""; }; const handleExportPng = () => { if (!qrState.ok) return; const canvas = document.createElement("canvas"); canvas.width = 2048; canvas.height = 2048; drawQR(canvas, options); triggerDownload(canvas.toDataURL("image/png"), "qr-designer.png"); }; const handleExportSvg = () => { if (!qrState.ok) return; const isLogoReady = !logo || logo.src; if (!isLogoReady) return; const svg = buildSVG(options); const blob = new Blob([svg], { type: "image/svg+xml" }); triggerDownload(URL.createObjectURL(blob), "qr-designer.svg"); }; return (

QR Designer

Style your scannable squares.

Generate a QR code, pick a module style, drop a logo in the middle, and download it as a crisp PNG or an infinitely scalable SVG.

Options

Content