EmpireUI
Get Pro
← Blog7 min read#qr-code#react#tailwind

QR Code Generator in React: Custom Logo, Download, Share

Build a QR code generator in React with custom logo overlay, PNG download, and share API — complete code, zero paid libraries, Tailwind-styled.

Black and white QR code printed on paper with a smartphone scanning it

Why Build Your Own QR Code Generator?

Honestly, most QR code libraries ship a mountain of options you'll never touch and charge a monthly fee for the one feature you actually need — logo overlay. Building your own takes less time than you'd think, and you end up owning every pixel.

The core dependency is qrcode (npm: qrcode, ~40 kB minified). It renders a QR matrix to a <canvas> element and accepts error-correction levels from L (7% recovery) to H (30% recovery). Logo embedding requires H because the logo itself covers part of the code — you need the extra redundancy.

We'll wire everything up to a clean React component with a Tailwind v4.0.2 interface: a text input, color pickers for foreground and background, an image upload for the logo, and two action buttons — Download PNG and Share via the Web Share API. No subscription. No watermark.

Installing Dependencies and Project Setup

Start with a standard Next.js 15 or Vite + React 18 project. You only need two packages: qrcode for generation and @types/qrcode for TypeScript types.

npm install qrcode
npm install -D @types/qrcode

If you're on Next.js, mark the component 'use client' at the top — it touches the DOM canvas directly, so it can't run on the server. In Vite you don't need that directive. Either way, keep the heavy canvas logic inside a useEffect so it fires only after mount.

Generating the QR Code on a Canvas

The generator function is the engine. It calls QRCode.toCanvas(), gets back a rendered canvas, then optionally stamps a logo image on top using a second draw call. The logo sits centred at 20% of the total canvas size — small enough that error-correction level H can still recover it.

'use client';
import { useEffect, useRef, useState } from 'react';
import QRCode from 'qrcode';

interface QROptions {
  value: string;
  size?: number;
  fgColor?: string;
  bgColor?: string;
  logoSrc?: string;
}

export function useQRCanvas({ value, size = 300, fgColor = '#000000', bgColor = '#ffffff', logoSrc }: QROptions) {
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !value) return;

    QRCode.toCanvas(canvas, value, {
      width: size,
      margin: 2,
      errorCorrectionLevel: 'H',
      color: { dark: fgColor, light: bgColor },
    }).then(() => {
      if (!logoSrc) return;
      const ctx = canvas.getContext('2d');
      if (!ctx) return;

      const logo = new Image();
      logo.crossOrigin = 'anonymous';
      logo.onload = () => {
        const logoSize = size * 0.2;
        const x = (size - logoSize) / 2;
        const y = (size - logoSize) / 2;
        // White padding behind logo
        ctx.fillStyle = bgColor;
        ctx.fillRect(x - 6, y - 6, logoSize + 12, logoSize + 12);
        ctx.drawImage(logo, x, y, logoSize, logoSize);
      };
      logo.src = logoSrc;
    });
  }, [value, size, fgColor, bgColor, logoSrc]);

  return canvasRef;
}

Notice the ctx.fillRect call before drawing the image. That 6 px white padding ensures the logo doesn't bleed into surrounding QR modules, which causes scanner confusion even with high error-correction. Eight pixels works too — below 4 and some readers start struggling.

Building the QR Generator UI with Tailwind

The UI component consumes the hook above and wires up the controls. Color inputs use native <input type="color"> because they're free and every browser supports them. The logo uploader converts the File to an object URL so the canvas can load it without a server round-trip.

import { useState } from 'react';
import { useQRCanvas } from './useQRCanvas';

export default function QRGenerator() {
  const [value, setValue] = useState('https://empire-ui.com');
  const [fgColor, setFgColor] = useState('#000000');
  const [bgColor, setBgColor] = useState('#ffffff');
  const [logoSrc, setLogoSrc] = useState<string | undefined>();

  const canvasRef = useQRCanvas({ value, fgColor, bgColor, logoSrc });

  const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (file) setLogoSrc(URL.createObjectURL(file));
  };

  const handleDownload = () => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const link = document.createElement('a');
    link.download = 'qr-code.png';
    link.href = canvas.toDataURL('image/png');
    link.click();
  };

  const handleShare = async () => {
    const canvas = canvasRef.current;
    if (!canvas || !navigator.share) return;
    canvas.toBlob(async (blob) => {
      if (!blob) return;
      const file = new File([blob], 'qr-code.png', { type: 'image/png' });
      await navigator.share({ files: [file], title: 'My QR Code' });
    }, 'image/png');
  };

  return (
    <div className="flex flex-col items-center gap-6 p-8 max-w-md mx-auto">
      <input
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        placeholder="Enter URL or text"
        className="w-full px-4 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700
                   bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2
                   focus:ring-indigo-500"
      />

      <div className="flex gap-6 items-center text-sm">
        <label className="flex flex-col items-center gap-1">
          Foreground
          <input type="color" value={fgColor} onChange={(e) => setFgColor(e.target.value)}
                 className="w-10 h-10 cursor-pointer rounded border-0" />
        </label>
        <label className="flex flex-col items-center gap-1">
          Background
          <input type="color" value={bgColor} onChange={(e) => setBgColor(e.target.value)}
                 className="w-10 h-10 cursor-pointer rounded border-0" />
        </label>
        <label className="flex flex-col items-center gap-1 cursor-pointer">
          Logo
          <input type="file" accept="image/*" onChange={handleLogoUpload} className="hidden" />
          <span className="text-xs px-3 py-1 rounded bg-neutral-100 dark:bg-neutral-800">Upload</span>
        </label>
      </div>

      <canvas ref={canvasRef} className="rounded-xl shadow-md" />

      <div className="flex gap-3">
        <button onClick={handleDownload}
                className="px-5 py-2 rounded-lg bg-indigo-600 text-white text-sm
                           hover:bg-indigo-700 active:scale-95 transition-all">
          Download PNG
        </button>
        <button onClick={handleShare}
                className="px-5 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600
                           text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800
                           active:scale-95 transition-all">
          Share
        </button>
      </div>
    </div>
  );
}

The active:scale-95 transition-all on both buttons gives that satisfying press feedback — the same micro-interaction pattern we covered in animated button components. It's 2 Tailwind classes and it makes the UI feel noticeably more alive.

Adding Dark Mode and Glass Style Support

A plain white QR code looks dated inside a dark UI. The fix is to expose fgColor and bgColor as props and default them to the user's current theme. You can read the current theme with a one-liner if you're using Empire UI's theme toggle pattern.

For glassmorphism UIs, set bgColor to rgba(255,255,255,0) — fully transparent — and render the canvas on top of your gradient or image layer. It works because qrcode writes the background fill first, then the modules. A transparent background lets the backdrop show through the quiet zones. If you want to go deeper on that frosted aesthetic, what is glassmorphism covers the backdrop-filter setup in detail.

One gotcha: canvas.toDataURL() flattens the canvas to a flat PNG at export time. If your background is transparent, the downloaded PNG will have a transparent background too — great for overlaying on brand materials, but some QR scanner apps struggle with transparent quiet zones. Add a white fill rect as a safety net when bgColor contains an alpha below 0.5.

Error Correction Levels and Logo Size Limits

Here's the thing: error correction level H lets you obscure up to 30% of the QR code surface and still decode successfully. That sounds like a lot of room, but modules aren't evenly distributed — the three finder patterns in the corners are sacred, and the timing strips between them can't be covered at all.

In practice, keep your logo to 20–25% of the total canvas area. At 300 px that's a 60–75 px logo. Going wider than 25% and you'll start hitting false negatives on cheaper phone cameras in low light. It's worth testing with the Google Lens scanner on a mid-range Android — it's the most demanding scanner in common use and it'll tell you fast if your logo is too big.

The margin: 2 parameter in the options sets the quiet zone to 2 modules wide. The QR spec recommends 4, but 2 is fine for screens where there's natural background contrast. For print — business cards, packaging — bump it to 4.

Download as PNG and Web Share API

The download handler calls canvas.toDataURL('image/png') and injects a temporary <a> tag with the download attribute. This works across all modern browsers without any server involvement. The generated PNG is at whatever pixel density the canvas was drawn at — if you want a 2x retina export, multiply size by window.devicePixelRatio before calling QRCode.toCanvas, then reset the CSS width via canvas.style.width.

The Web Share API (navigator.share) is available in Chrome for Android, Safari iOS 14+, and Edge. It's not available in Firefox desktop or most Chromium Linux builds. Always guard it: if (!navigator.share) return; — or better yet, hide the Share button entirely when the API isn't present so you're not teasing users with a dead button. A clean component file for share-capable UI buttons is worth keeping in your library alongside cards and tabs components like the ones in react carousel patterns.

The canvas.toBlob() call in the share handler is asynchronous and returns the PNG binary. Wrapping it in a File object with type: 'image/png' is required — navigator.share only accepts File objects in the files array, not raw Blobs.

Embedding the QR Generator in a Card Layout

Wrapping the generator in a card gives it a finished, deployable look. A max-w-md container with rounded-2xl shadow-lg and 24 px padding is a good starting point. The canvas itself should be centred with a light border — border border-neutral-100 dark:border-neutral-800 rounded-xl — so it floats rather than sitting flat on the page.

If you're building a dashboard or admin panel, you might want multiple QR codes per screen — one per product, one per location. Wrapping each in a bento grid cell works well: fixed-height cells, canvas scales to fill, download button pinned to the bottom. Each card can take its value prop from an API response and generate on mount.

For the hover state on the card wrapper, transition-shadow duration-200 hover:shadow-xl is enough — you don't need JavaScript. The whole component, including the hook, lands at under 5 kB before Tailwind purge. That's the kind of weight budget that lets you ship without second-guessing yourself.

FAQ

Which npm package should I use to generate QR codes in React?

Use the qrcode package (not qrcodejs or react-qr-code). It outputs directly to a canvas element, which gives you full control for logo overlays and PNG export. Install it with npm install qrcode and npm install -D @types/qrcode for TypeScript support.

Why do I need error correction level H when adding a logo?

Error correction level H allows up to 30% of the QR code surface to be damaged or obscured and still decode correctly. A centred logo typically covers 20-25% of the canvas area, so you need that extra headroom. Levels L, M, or Q don't leave enough redundancy and the code will fail to scan once the logo is placed.

How do I export the QR code canvas as a PNG file?

Call canvas.toDataURL('image/png') to get a base64 data URL, then create a temporary anchor element: const a = document.createElement('a'); a.download = 'qr.png'; a.href = dataUrl; a.click();. This triggers a native browser download without any server round-trip.

The Web Share API isn't working in my browser. What should I check?

Web Share API with files support is limited to Chrome on Android, Safari iOS 14+, and Edge on Windows. It's not available in Firefox desktop or most Chromium Linux builds. Always guard with if (!navigator.share) and consider hiding the Share button entirely when the API is unavailable rather than showing a disabled state.

How do I make the QR code transparent for use on branded backgrounds?

Pass bgColor: 'rgba(0,0,0,0)' (fully transparent) to QRCode.toCanvas. The quiet zone and module background will be transparent, letting your gradient or image show through. Note that canvas.toDataURL('image/png') will preserve the transparency in the exported file, which some QR scanner apps handle poorly — add a white fill rect first if you need solid-background exports.

What's the maximum logo size that still scans reliably?

Keep the logo to 20-25% of the total canvas area. At 300 px canvas that means a 60-75 px logo image. Beyond 25% you'll see failures on low-end phone cameras and in poor lighting conditions. Always test with Google Lens on an Android device — it's the most strict scanner in common daily use.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Custom Video Player in React: Controls, Progress, FullscreenStar Rating Component in React: Accessible, Animated, ThemeableConfetti Animation in React: Celebration UI for MilestonesWhat Is Glassmorphism? A Free React + Tailwind Guide