Skip to contentVibraUI
Utilities

QR code

A QR symbol as one SVG path, black on a white plate in every theme, quiet zone included.

The matrix comes from qrcode's create(), and the drawing is this component's own: no canvas, no <img>. Every horizontal run of dark modules becomes one rectangle in a single path, painted black on a white plate that fills the viewBox — the one documented exception to the tokens rule. ISO 18004 defines a symbol as dark on light, and a scanner that does not try inversion (ZXing's default, several authenticator apps) reads nothing else; drawn in currentColor, the dark theme inverted the symbol and the two-factor page could not be enrolled from, so the plate and the ink are fixed rather than the page's. Runs rather than a rect per module for a reason beyond size: neighbouring rects seam under a fractional scale factor where one path does not, and shapeRendering="crispEdges" keeps the modules on whole pixels instead of antialiasing the symbol grey. The spec's four-module quiet zone is inside the viewBox rather than a prop, because the minimum is the only value that is ever right and a symbol printed without it is one nothing can read; the plate carries it, so it is white in every theme. A value that is empty, or past the format's capacity (~2,950 bytes at level L), renders an empty frame carrying data-empty and the same accessible name instead of throwing — a QR that cannot be made should not take a page down with it. qrModules is exported for anyone who wants the matrix without the drawing. The qrcode package's PNG renderer requires fs at module scope, so this component's server path renders on the Node runtime; an Edge-runtime route should render QrCode on the client instead.

Install

npx shadcn@latest add @vibra/qr-code

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring—What the symbol encodes — a URL, an otpauth string, a Wi-Fi credential.
sizenumber160Rendered edge in CSS pixels; the symbol scales to it.
level"L" | "M" | "Q" | "H""M"How much of the symbol may be obscured and still read: ~7%, ~15%, ~25%, ~30%. More correction, denser symbol.
labelstring—The image's accessible name. Without one the value itself is read out.
qrModules(value: string, level?: "L" | "M" | "Q" | "H") => boolean[][]—The symbol as rows of booleans, dark first. Empty when there is nothing to encode.

Dependencies

Source

components/ui/qr-code.tsx
import * as React from "react"
import { create } from "qrcode"

import { cn } from "@/lib/utils"

/**
 * How much of the symbol may be obscured and still read: L ~7%, M ~15%,
 * Q ~25%, H ~30%. More correction means a denser symbol for the same payload.
 */
export type QrCodeLevel = "L" | "M" | "Q" | "H"

/**
 * The four light modules a scanner needs around the symbol. Not a prop: the
 * spec's minimum is the only value that is ever right, and a symbol printed
 * without it is one nothing can read.
 */
const QUIET_ZONE = 4

/**
 * The plate and the ink, as literals — the one documented exception to the
 * tokens rule. ISO 18004 defines a symbol as dark modules on a light ground,
 * and a scanner that does not try inversion (ZXing's default, several
 * authenticator apps) reads nothing else. Drawn in `currentColor` on the page,
 * the dark theme inverted the symbol — white on near-black — and the
 * two-factor page could not be enrolled from. So the symbol is painted the
 * same in every theme: black on a white plate that fills the viewBox, quiet
 * zone included. The plate is not the page's paper on purpose.
 */
const PLATE = "#fff"
const INK = "#000"

/**
 * The symbol for `value` as rows of booleans, dark first.
 *
 * An empty matrix when there is nothing to encode, or when the payload is past
 * the format's capacity (~2,950 bytes at level L) — those are the two ways
 * `create` refuses, and neither should take a page down with it.
 */
export function qrModules(value: string, level: QrCodeLevel = "M"): boolean[][] {
  if (!value) return []

  let symbol
  try {
    symbol = create(value, { errorCorrectionLevel: level })
  } catch {
    return []
  }

  const { size, data } = symbol.modules
  return Array.from({ length: size }, (_, row) =>
    Array.from({ length: size }, (_, col) => data[row * size + col] === 1)
  )
}

/**
 * One `d` for the whole symbol: each horizontal run of dark modules as a
 * one-module-tall rectangle, offset by the quiet zone.
 *
 * Runs rather than a rectangle per module, because a 45x45 symbol is 2,025
 * squares and half of them are neighbours — and neighbouring rects seam under
 * a fractional scale factor where one path does not.
 */
function qrPath(modules: boolean[][]): string {
  const parts: string[] = []

  for (let row = 0; row < modules.length; row++) {
    let start = -1
    // One past the end, so a run reaching the right edge is closed by the same
    // branch that closes every other run.
    for (let col = 0; col <= modules.length; col++) {
      const dark = col < modules.length && modules[row][col]
      if (dark && start === -1) start = col
      if (!dark && start !== -1) {
        const width = col - start
        parts.push(`M${start + QUIET_ZONE} ${row + QUIET_ZONE}h${width}v1h-${width}z`)
        start = -1
      }
    }
  }

  return parts.join("")
}

export type QrCodeProps = React.ComponentProps<"svg"> & {
  /** What the symbol encodes — a URL, an OTP auth string, a Wi-Fi credential. */
  value: string
  /** Rendered edge in CSS pixels; the symbol scales to it. */
  size?: number
  level?: QrCodeLevel
  /** The image's accessible name; the value itself when there is none. */
  label?: string
}

/**
 * A QR symbol as an SVG: one path of black modules on a white plate, the same
 * in every theme.
 *
 * The matrix comes from `qrcode`'s `create`, and the drawing is this
 * component's own — no canvas, no image. The plate carries the spec's
 * four-module quiet zone inside the viewBox, so a scanner reads the symbol
 * whatever the page behind it is painted with, dark mode included.
 */
function QrCode({
  className,
  value,
  size = 160,
  level = "M",
  label,
  ...props
}: QrCodeProps) {
  const modules = qrModules(value, level)
  const side = modules.length + QUIET_ZONE * 2

  return (
    <svg
      data-slot="qr-code"
      data-level={level}
      data-empty={modules.length === 0 || undefined}
      role="img"
      // An empty value with no label would otherwise leave the image nameless
      // (an empty aria-label does not count as a name).
      aria-label={label || value || "QR code"}
      width={size}
      height={size}
      viewBox={`0 0 ${side} ${side}`}
      // Modules land on whole pixels only at some sizes; without this the
      // browser antialiases every edge and a scanner sees a grey symbol.
      shapeRendering="crispEdges"
      xmlns="http://www.w3.org/2000/svg"
      className={cn("shrink-0", className)}
      {...props}
    >
      <rect x={0} y={0} width={side} height={side} fill={PLATE} />
      {modules.length > 0 ? <path d={qrPath(modules)} fill={INK} /> : null}
    </svg>
  )
}

export { QrCode }