Generate QR codes in React with no dependency

Generate QR codes in React with no npm dependency: an SVG component with error correction levels, a logo well, SVG and PNG download and a share dialog.

By , MiniDev14 min read

You can generate a QR code in React without a dependency by encoding the text to bytes, adding Reed-Solomon error correction, placing the bits in a grid, choosing a mask, and drawing the dark modules as one SVG path. The free MiniDev UI QrCode does all of that in a single file, versions 1 to 40 and all four error correction levels, and exports the encoder so you can draw the same matrix to a canvas for PNG.

This guide covers the props that matter (size, level, colours, quiet zone, the logo well), builds a share dialog with Dialog and Button, shows the payload formats phones understand, adds PNG export, and lists what makes a code fail to scan in print and in dark mode.

What is inside a QR code

  • Modules are the squares. A version 1 symbol is 21 by 21; each version adds 4, up to 177 by 177 at version 40. The encoder picks the smallest version that fits.
  • Error correction adds redundant codewords so a damaged or partly covered code still reads. Level L recovers about 7 percent of codewords, M 15, Q 25 and H 30. More correction means a bigger symbol for the same text.
  • Function patterns are the three big finders, the alignment squares, the timing lines and the format strip. Scanners find and orient the code with them, which is why a logo must never cover a corner.
  • Masks XOR the data area with one of eight patterns to break up large blobs; the spec scores each and the encoder keeps the lowest penalty.

Capacity in byte mode, worked out from the table in the component: at level M a 30 character URL fits version 3 (29 modules) and a 60 character one needs version 4 (33 modules). The same 60 characters at level H need version 7 (45 modules). Short links make small, forgiving codes; this is the practical reason to put a redirect in front of long tracked URLs.

Install and render

bash
npx shadcn@latest add @minidev/qr-code
npx shadcn@latest add @minidev/dialog
npx shadcn@latest add @minidev/button
tsx
import { QrCode } from "@/components/ui/qr-code"

<QrCode value="https://ui.minidev.pro/docs/qr-code" size={200} level="M" />

That renders a card with the code, a caption (the URL's host and path by default) and a Download SVG button. The props, all optional:

PropDefaultWhat it does
valuethe kit URLText to encode as UTF-8 bytes: URLs, Wi-Fi strings, payment payloads
size200Rendered width and height in pixels; the SVG scales without blur
level"M"L, M, Q or H. A logo needs H
fg / bgcurrentColor / transparentModule and background colours, any CSS colour
margin2Quiet zone in modules on each side; the spec asks for 4
captionhost of a URL, or the valueText under the code; "" hides it
download / fileNametrue / qr-code.svgThe Download SVG button and its file name
barefalseOnly the SVG, no card, caption or button
titleQR code for <value>Accessible name of the image
logoScale0.24Width of the cleared centre well as a fraction of the symbol, with a logo
maskautoForce a mask pattern 0 to 7 instead of scoring all eight
childrenA logo or icon placed in the centre well; rendered only at level H

The modules draw in currentColor by default, so the code follows your text colour and tokens. That is right for an on screen code in a light theme and wrong in two cases covered below: dark mode and print.

A share dialog

The most common place for a QR code in a product is a share action: a link people can scan from a colleague's screen or copy. The kit's Dialog traps focus, locks scroll and returns focus to the trigger, so the panel only needs content:

components/share-invoice.tsxtsx
"use client"
import * as React from "react"
import { CheckIcon, LinkIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { QrCode } from "@/components/ui/qr-code"

export function ShareInvoice({ url, number }: { url: string; number: string }) {
  const [copied, setCopied] = React.useState(false)
  const copy = async () => {
    try {
      await navigator.clipboard.writeText(url)
      setCopied(true)
      setTimeout(() => setCopied(false), 1500)
    } catch {}
  }
  return (
    <Dialog>
      <DialogTrigger render={<Button variant="outline" />}>Share</DialogTrigger>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Share invoice {number}</DialogTitle>
          <DialogDescription>Scan to open the payment page, or copy the link.</DialogDescription>
        </DialogHeader>
        <div className="flex justify-center">
          <QrCode value={url} size={220} level="Q" fg="black" bg="white" margin={3} caption={number} fileName={"invoice-" + number + ".svg"} />
        </div>
        <DialogFooter showCloseButton>
          <Button onClick={copy} aria-live="polite">
            {copied ? <CheckIcon /> : <LinkIcon />}
            {copied ? "Copied" : "Copy link"}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  )
}
  • fg="black" and bg="white" fix the colours regardless of theme. A code that inverts in dark mode (light modules on a dark card) scans less reliably, because many readers assume dark on light.
  • Level Q is a good default for a code someone will scan off a screen at an angle; it costs one or two versions over M for a short URL.
  • The Download button already lives in the card (download is on by default) and says "Saved" for a moment after writing the file, announced through aria-live.
  • The copy button repeats the value as text so the dialog is useful to people who cannot scan, including anyone on the device that is showing the code.

Pass a child and set level="H". The component clears a centred square well (logoScale of the symbol width, snapped to whole modules) and renders the child inside it, slightly inset. At any other level the child is ignored, because the cleared modules would exceed what the correction can recover.

tsx
<QrCode value="https://lumen.app/r/q3-revenue" level="H" size={240} logoScale={0.22}>
  <img src="/brand/mark.svg" alt="" className="size-full rounded-md bg-white p-1" />
</QrCode>

Keep logoScale at or below the default 0.24. Level H recovers 30 percent of codewords, and a 24 percent wide well removes under 6 percent of the area, but a logo also costs the scanner contrast around its edges. Give the logo a solid background, keep the value short so the symbol has more modules than the well, and test with a phone camera before shipping; a logo that looks fine at 240 pixels can break a code printed at 2 centimetres.

Payloads phones understand

A QR code is just text; what the phone does with it depends on the format. The common ones:

PurposeValueNote
Open a pagehttps://lumen.app/r/q3Use the scheme; bare domains are not always treated as links
Join Wi-FiWIFI:T:WPA;S:Lumen Guest;P:secret;;Escape \, ;, ,, " and : in the SSID and password with a backslash
Call or texttel:+14155550123 or SMSTO:+14155550123:Hi
Emailmailto:billing@lumen.app?subject=Invoice%201041Percent encode the query
Locationgeo:37.7749,-122.4194Opens the maps app
Contact cardBEGIN:VCARD ... END:VCARDvCard 3.0; long, so expect version 10 or more
PaymentAn EPC string, a UPI URI, a Lightning invoiceEach scheme has its own spec; encode exactly what it asks
ts
const esc = (s: string) => s.replace(/([\\;,":])/g, "\\$1")
export const wifi = (ssid: string, password: string, kind: "WPA" | "WEP" | "nopass" = "WPA") =>
  "WIFI:T:" + kind + ";S:" + esc(ssid) + ";P:" + esc(password) + ";;"

Everything is encoded as UTF-8 bytes. The component does not use the numeric or alphanumeric modes, which pack digits and upper case letters more tightly; a 16 digit number is therefore a version 1 code at level M rather than something smaller still, which never matters in practice.

Export as PNG

SVG is the right file for print and design tools. Chat apps and some CMS uploaders want a raster. encodeQr is exported from the same file, so draw its matrix to a canvas:

lib/qr-png.tsts
import { encodeQr, type QrLevel } from "@/components/ui/qr-code"

/** Returns a PNG data URL, or null when the value does not fit the level. */
export function qrToPng(value: string, level: QrLevel = "M", scale = 8, margin = 4): string | null {
  const qr = encodeQr(value, level)
  if (!qr) return null
  const view = qr.size + margin * 2
  const canvas = document.createElement("canvas")
  canvas.width = view * scale
  canvas.height = view * scale
  const ctx = canvas.getContext("2d")
  if (!ctx) return null
  ctx.fillStyle = "#ffffff"
  ctx.fillRect(0, 0, canvas.width, canvas.height)
  ctx.fillStyle = "#000000"
  for (let r = 0; r < qr.size; r++) {
    for (let c = 0; c < qr.size; c++) {
      if (qr.modules[r * qr.size + c]) ctx.fillRect((c + margin) * scale, (r + margin) * scale, scale, scale)
    }
  }
  return canvas.toDataURL("image/png")
}

qr.modules is a row major Uint8Array with 1 for dark, already masked, and qr.size is the module count. A scale of 8 gives a 264 pixel PNG for version 1 and about 1,500 for version 40, which prints cleanly. Trigger the download the same way the component does: a Blob, URL.createObjectURL, an anchor with download, then revoke the URL.

Dynamic values

The component memoises the matrix on value, level and mask, so re-rendering for unrelated state is free. Encoding itself is fast for short values; a version 40 code scores eight masks over 31,000 modules each, which takes a few milliseconds. Two habits keep a live preview smooth:

  • Debounce typed input by about 150 milliseconds before it reaches value, so the matrix is not rebuilt on every keystroke of a long vCard.
  • When generating many codes at once (a sheet of asset tags), pass a fixed mask such as mask={2} to skip the penalty scoring. The result is still a valid code, just not the optimal mask, and scanners do not care.

When the value is too long for the level, encodeQr returns null and the component renders a hatched placeholder that says "Too long for level M". Catch it earlier in a form by calling encodeQr yourself and showing a count, or drop to level L automatically for long values.

Print and scanning reliability

  • Use margin={4} for anything printed. The quiet zone is how scanners separate the code from whatever surrounds it; two modules is fine on a plain card background on screen, not on a busy flyer.
  • Dark modules on a light background. The Download SVG button already substitutes black and white when fg is currentColor and bg is transparent, so downloaded files are always scannable even from a dark theme.
  • Size for the scanning distance. A rough rule is one tenth of the distance: a code read from 30 centimetres should be 3 centimetres wide. On screen, keep each module at 3 pixels or more; size / (modules + 2 * margin) tells you the module size.
  • Low contrast brand colours are a scanning risk. If fg must be a brand colour, keep it dark (an OKLCH lightness under 0.4 is a reasonable line) and leave bg white.
  • Test the printed thing with two different phones. Camera apps differ, and the only test that counts is the one on paper.

Accessibility

  • The SVG has role="img" and an aria-label of "QR code for <value>". Set title to something kinder for long payloads: "QR code: join the Lumen guest Wi-Fi".
  • A QR code is useless to someone who cannot scan it, including the person whose screen it is on. Always put the same information next to it as text or a copy button, as the share dialog does.
  • The caption truncates to the code's width with a title attribute holding the full value, so hovering shows it.
  • The Download button is a real button with a visible label and an aria-live region for the "Saved" confirmation.

Pitfalls

  • A logo at level M. It is silently not rendered, because the well would exceed the correction budget. Set level="H".
  • Inverted codes in dark mode. currentColor on a dark surface gives light modules. Fix fg and bg for anything that will be scanned.
  • Tracking parameters. A 200 character UTM laden URL is a version 10 or larger symbol with tiny modules. Use a short redirect and add the parameters server side.
  • Encoding the display string. Encode the canonical value (https://, exact case). Case matters in paths, and scanners do not autocorrect.
  • Rendering hundreds of codes in a list. Each one is an SVG path; that is fine for dozens. For a 500 row table, render codes on demand in a row's detail view or generate PNGs in a worker.

Components used in this guide

QrCode, Dialog and Button are free and MIT licensed; other sharing and feedback pieces are in the feedback category. The share dialog follows the patterns in the accessible modal guide, and the invoice page it belongs on is built in the billing page guide. If you want the whole product built, the MiniDev studio builds on this kit.

Qr CodeuiA React QR code component with its own encoder: byte mode, versions 1 to 40, four error correction levels, crisp SVG in currentColor, a logo well and an SVG download button.npx shadcn@latest add @minidev/qr-codeDialoguiShadcn compatible modal dialog on Base UI with a blurred backdrop, enter and exit animation, close button, header, title, description and a footer bar.npx shadcn@latest add @minidev/dialog
bash
npx shadcn@latest add https://ui.minidev.pro/r/<name>.json
# or the package
npm i minidev-ui-kit

Components used in this guide

Frequently asked questions

Can I generate a QR code in React without a library?

Yes. The encoder is byte mode data, Reed-Solomon error correction over GF(256), module placement, and mask selection, which fits in a few hundred lines. The kit's QrCode component contains the whole encoder and renders the result as an SVG path, with no npm dependency.

Which error correction level should I use?

M for codes on screen, Q for codes that will be scanned at an angle or printed small, H when a logo sits in the middle. Higher levels make the symbol larger for the same text, so keep the value short.

Why does my QR code not scan in dark mode?

Because the modules follow the text colour and render light on dark, which many scanners handle poorly. Pass explicit colours (fg="black", bg="white") and a quiet zone of at least two modules, and four for print.

More guides