A QR code drawn as accessible SVG: square, rounded or dotted modules that keep scannable corner markers, a logo slot, error-correction levels, a clear message when text is too long, and PNG and SVG downloads.
Point a phone camera at it to open the page.
"use client"
import * as React from "react"
import { Download } from "lucide-react"
import { Button } from "@/components/ballmac/button"
import { downloadQRCode, QRCode } from "@/components/ballmac/qr-code"
export default function QRCodeDemo() {
const svg = React.useRef<SVGSVGElement>(null)
return (
<div className="flex flex-col items-center gap-4">
<div className="rounded-2xl border bg-card p-3 shadow-xs">
<QRCode value="https://ui.ballmac.com/components/qr-code" size={200} svgRef={svg} />
</div>
<p className="max-w-52 text-center text-sm text-muted-foreground">Point a phone camera at it to open the page.</p>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => svg.current && downloadQRCode(svg.current, { filename: "ballmac-qr", format: "png" })}>
<Download aria-hidden="true" />
PNG
</Button>
<Button size="sm" variant="outline" onClick={() => svg.current && downloadQRCode(svg.current, { filename: "ballmac-qr", format: "svg" })}>
<Download aria-hidden="true" />
SVG
</Button>
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/qr-codeInstall the dependencies.
$ pnpm add uqr@^0.1Add the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/qr-code.tsx// Ballmac UI: QR Code. https://ui.ballmac.com/components/qr-code // Based on uqr (MIT, Copyright (c) 2023 Anthony Fu), which makes the code; this draws it as accessible SVG with shapes, a logo slot and downloads. "use client" import * as React from "react" import { encode, QrCodeDataType } from "uqr" import { useMessages } from "@/lib/ballmac/i18n" import { cn } from "@/lib/utils" type QRCodeShape = "square" | "rounded" | "dots" type QRCodeLevel = "L" | "M" | "Q" | "H" type QRCodeProps = Omit<React.ComponentProps<"div">, "children"> & { /** What the code holds: a web address, text, a Wi-Fi string or anything else. */ value: string /** Pixel size of the whole code, quiet zone included. */ size?: number /** How much damage it survives: L 7%, M 15%, Q 25%, H 30%. More makes a denser code. A logo raises it to at least Q. */ level?: QRCodeLevel /** Shape of the dark modules. The three corner squares keep their own style so scanners find them. */ shape?: QRCodeShape /** Empty modules around the code. Scanners need at least 4. */ quietZone?: number /** Dark color. Codes scan best dark on light, so the defaults stay that way in dark mode. Any CSS color. */ foreground?: string /** Light color. */ background?: string /** Something drawn in the middle, such as a logo. Its area is cleared. */ logo?: React.ReactNode /** Share of the width the logo takes, up to 0.3. */ logoSize?: number /** Names the code for screen readers. Defaults to "QR code for {value}". */ label?: string /** Called when the value cannot be encoded. */ onError?: (error: Error) => void /** The SVG element, for downloads. */ svgRef?: React.Ref<SVGSVGElement> } type Matrix = { size: number; data: boolean[][]; types: QrCodeDataType[][] } /** Make the code, or say why it cannot be made. Longer text than version 40 holds is the usual reason. */ export function makeMatrix(value: string, level: QRCodeLevel): { matrix: Matrix } | { error: Error } { try { const r = encode(value, { ecc: level, border: 0, boostEcc: false }) return { matrix: { size: r.size, data: r.data, types: r.types as QrCodeDataType[][] } } } catch (e) { return { error: e instanceof Error ? e : new Error(String(e)) } } } const n = (v: number) => Math.round(v * 1000) / 1000 /** Path data for the dark modules, merging runs along a row, so a big code stays a small file. */ function squarePath(m: Matrix, skip: (x: number, y: number) => boolean) { let d = "" for (let y = 0; y < m.size; y++) { let x = 0 while (x < m.size) { if (!m.data[y]![x] || skip(x, y)) { x++ continue } let run = 1 while (x + run < m.size && m.data[y]![x + run] && !skip(x + run, y)) run++ d += `M${x} ${y}h${run}v1h${-run}z` x += run } } return d } function roundedPath(m: Matrix, skip: (x: number, y: number) => boolean, r = 0.32) { let d = "" const on = (x: number, y: number) => x >= 0 && y >= 0 && x < m.size && y < m.size && m.data[y]![x] && !skip(x, y) for (let y = 0; y < m.size; y++) for (let x = 0; x < m.size; x++) { if (!on(x, y)) continue // a corner is rounded only where no neighbour touches it, so joined modules read as one shape const tl = !on(x - 1, y) && !on(x, y - 1) ? r : 0 const tr = !on(x + 1, y) && !on(x, y - 1) ? r : 0 const br = !on(x + 1, y) && !on(x, y + 1) ? r : 0 const bl = !on(x - 1, y) && !on(x, y + 1) ? r : 0 d += `M${n(x + tl)} ${y}H${n(x + 1 - tr)}${tr ? `A${r} ${r} 0 0 1 ${x + 1} ${n(y + tr)}` : ""}V${n(y + 1 - br)}${br ? `A${r} ${r} 0 0 1 ${n(x + 1 - br)} ${y + 1}` : ""}H${n(x + bl)}${bl ? `A${r} ${r} 0 0 1 ${x} ${n(y + 1 - bl)}` : ""}V${n(y + tl)}${tl ? `A${r} ${r} 0 0 1 ${n(x + tl)} ${y}` : ""}z` } return d } function dotsPath(m: Matrix, skip: (x: number, y: number) => boolean) { let d = "" const r = 0.44 for (let y = 0; y < m.size; y++) for (let x = 0; x < m.size; x++) if (m.data[y]![x] && !skip(x, y)) d += `M${n(x + 0.5 - r)} ${n(y + 0.5)}a${r} ${r} 0 1 0 ${n(2 * r)} 0a${r} ${r} 0 1 0 ${n(-2 * r)} 0z` return d } /** One of the three corner markers: a ring of seven, a gap, and a block of three. Drawn with even-odd fill so the gap is open. */ function finder(x: number, y: number, shape: QRCodeShape) { if (shape === "square") return `M${x} ${y}h7v7h-7zM${x + 1} ${y + 1}v5h5v-5zM${x + 2} ${y + 2}h3v3h-3z` const ring = (px: number, py: number, s: number, r: number) => `M${px + r} ${py}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` const hole = (px: number, py: number, s: number, r: number) => `M${px + r} ${py}a${r} ${r} 0 0 0 ${-r} ${r}v${s - 2 * r}a${r} ${r} 0 0 0 ${r} ${r}h${s - 2 * r}a${r} ${r} 0 0 0 ${r} ${-r}v${-(s - 2 * r)}a${r} ${r} 0 0 0 ${-r} ${-r}z` const outer = 1.6 const inner = 1.1 return `${ring(x, y, 7, outer)}${hole(x + 1, y + 1, 5, outer - 0.6)}${ring(x + 2, y + 2, 3, inner)}` } /** * A QR code as SVG: it scales without blurring, has a name for screen readers, and can hold a logo. The shapes keep the three corner markers * recognizable, so rounded and dotted codes still scan. A value that is too long is reported, not drawn wrong. */ function QRCode({ value, size = 192, level = "M", shape = "square", quietZone = 4, foreground = "rgb(0, 0, 0)", background = "rgb(255, 255, 255)", logo, logoSize = 0.22, label, onError, svgRef, className, style, ...props }: QRCodeProps) { const msg = useMessages() const effective: QRCodeLevel = logo && (level === "L" || level === "M") ? "Q" : level const made = React.useMemo(() => (value ? makeMatrix(value, effective) : null), [value, effective]) const failed = made && "error" in made ? made.error : null const onErrorRef = React.useRef(onError) onErrorRef.current = onError React.useEffect(() => { if (failed) onErrorRef.current?.(failed) }, [failed]) const clamp = Math.min(Math.max(logoSize, 0.1), 0.3) const shown = made && "matrix" in made ? made.matrix : null const total = shown ? shown.size + quietZone * 2 : 0 const parts = React.useMemo(() => { if (!shown) return null const m = shown const c = (m.size - 1) / 2 const half = (m.size * clamp) / 2 const inLogo = (x: number, y: number) => !!logo && Math.abs(x - c) <= half && Math.abs(y - c) <= half const isFinder = (x: number, y: number) => (x < 8 && y < 8) || (x >= m.size - 8 && y < 8) || (x < 8 && y >= m.size - 8) const skip = (x: number, y: number) => inLogo(x, y) || (shape !== "square" ? isFinder(x, y) : false) const body = shape === "square" ? squarePath(m, (x, y) => inLogo(x, y)) : shape === "rounded" ? roundedPath(m, skip) : dotsPath(m, skip) // the finders are drawn whole, the separator row around them stays empty const finders = shape === "square" ? "" : `${finder(0, 0, shape)}${finder(m.size - 7, 0, shape)}${finder(0, m.size - 7, shape)}` return { body, finders } }, [shown, shape, logo, clamp]) const name = label ?? msg("qr-code.label", "QR code for {value}", { value: value.length > 80 ? `${value.slice(0, 77)}…` : value }) if (!value) { return ( <div data-slot="qr-code" data-state="empty" className={cn("flex items-center justify-center rounded-xl border border-dashed text-center text-sm text-muted-foreground", className)} style={{ width: size, height: size, ...style }} {...props}> {msg("qr-code.empty", "Nothing to encode yet")} </div> ) } if (failed || !shown || !parts) { return ( <div data-slot="qr-code" data-state="error" role="alert" className={cn("flex items-center justify-center rounded-xl border border-destructive/40 bg-destructive/5 p-3 text-center text-sm text-destructive", className)} style={{ width: size, height: size, ...style }} {...props}> {msg("qr-code.tooLong", "This is too long to fit in a QR code. Use a shorter link.")} </div> ) } const unit = size / total const quiet = quietZone return ( <div data-slot="qr-code" data-shape={shape} className={cn("relative inline-block shrink-0 overflow-hidden rounded-xl", className)} style={{ width: size, height: size, background, ...style }} {...props}> <svg ref={svgRef} role="img" aria-label={name} viewBox={`0 0 ${total} ${total}`} width={size} height={size} shapeRendering={shape === "square" ? "crispEdges" : "geometricPrecision"} xmlns="http://www.w3.org/2000/svg"> <title>{name}</title> <rect width={total} height={total} fill={background} /> <g transform={`translate(${quiet} ${quiet})`} fill={foreground}> <path d={parts.body} /> {parts.finders ? <path d={parts.finders} fillRule="evenodd" /> : null} </g> </svg> {logo ? ( <div data-slot="qr-code-logo" className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center overflow-hidden rounded-lg" style={{ background, width: Math.round(unit * shown.size * clamp), height: Math.round(unit * shown.size * clamp) }} > {logo} </div> ) : null} </div> ) } /** The code as an SVG file's text, to save or send: `value`, with the same options as the component. */ export function qrToSvg(value: string, { level = "M", shape = "square", quietZone = 4, size = 512, foreground = "rgb(0, 0, 0)", background = "rgb(255, 255, 255)" }: Partial<Pick<QRCodeProps, "level" | "shape" | "quietZone" | "size" | "foreground" | "background">> = {}) { const made = makeMatrix(value, level) if ("error" in made) throw made.error const m = made.matrix const total = m.size + quietZone * 2 const none = () => false const body = shape === "square" ? squarePath(m, none) : shape === "rounded" ? roundedPath(m, (x, y) => (x < 8 && y < 8) || (x >= m.size - 8 && y < 8) || (x < 8 && y >= m.size - 8)) : dotsPath(m, (x, y) => (x < 8 && y < 8) || (x >= m.size - 8 && y < 8) || (x < 8 && y >= m.size - 8)) const finders = shape === "square" ? "" : `${finder(0, 0, shape)}${finder(m.size - 7, 0, shape)}${finder(0, m.size - 7, shape)}` return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${total} ${total}" width="${size}" height="${size}"><rect width="${total}" height="${total}" fill="${background}"/><g transform="translate(${quietZone} ${quietZone})" fill="${foreground}"><path d="${body}"/>${finders ? `<path d="${finders}" fill-rule="evenodd"/>` : ""}</g></svg>` } /** * Save the code the person is looking at, as an SVG or a PNG. Call it from a button's click. A PNG is drawn on a canvas at `size` pixels. */ export async function downloadQRCode(svg: SVGSVGElement, { filename = "qr-code", format = "png", size = 1024 }: { filename?: string; format?: "svg" | "png"; size?: number } = {}) { const xml = new XMLSerializer().serializeToString(svg) const save = (blob: Blob, ext: string) => { const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = `${filename}.${ext}` a.click() setTimeout(() => URL.revokeObjectURL(url), 1000) } if (format === "svg") return save(new Blob([xml], { type: "image/svg+xml" }), "svg") const img = new Image() img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(xml)}` await img.decode() const canvas = document.createElement("canvas") canvas.width = canvas.height = size const ctx = canvas.getContext("2d") if (!ctx) throw new Error("A canvas is not available.") ctx.imageSmoothingEnabled = false ctx.drawImage(img, 0, 0, size, size) await new Promise<void>((resolve, reject) => canvas.toBlob((b) => (b ? (save(b, "png"), resolve()) : reject(new Error("Could not make the image."))), "image/png")) } export { QRCode, type QRCodeLevel, type QRCodeProps, type QRCodeShape }Update the import paths to match your project setup.
Usage
import { QRCode } from "@/components/ballmac/qr-code"The full example is in the Code tab above.
Examples
Shapes
"use client"
import { QRCode, type QRCodeShape } from "@/components/ballmac/qr-code"
const shapes: { shape: QRCodeShape; name: string }[] = [
{ shape: "square", name: "Square" },
{ shape: "rounded", name: "Rounded" },
{ shape: "dots", name: "Dots" },
]
export default function QRCodeShapes() {
return (
<div className="flex flex-wrap items-start justify-center gap-6">
{shapes.map(({ shape, name }) => (
<figure key={shape} className="flex flex-col items-center gap-2">
<div className="rounded-xl border p-2">
<QRCode value="https://ui.ballmac.com" shape={shape} size={136} level="Q" />
</div>
<figcaption className="text-sm text-muted-foreground">{name}</figcaption>
</figure>
))}
</div>
)
}With a logo
import { QRCode } from "@/components/ballmac/qr-code"
export default function QRCodeLogo() {
return (
<QRCode
value="https://ui.ballmac.com/presets"
size={220}
shape="rounded"
logo={
<span aria-hidden="true" className="flex size-full items-center justify-center rounded-lg bg-foreground text-sm font-semibold tracking-tight text-background">
B
</span>
}
label="QR code for the Ballmac presets page"
/>
)
}Wi-Fi code, live
"use client"
import * as React from "react"
import { QRCode, type QRCodeLevel } from "@/components/ballmac/qr-code"
const levels: QRCodeLevel[] = ["L", "M", "Q", "H"]
export default function QRCodeLive() {
const [ssid, setSsid] = React.useState("Studio Guest")
const [password, setPassword] = React.useState("open-sesame-42")
const [level, setLevel] = React.useState<QRCodeLevel>("M")
// the standard text that phones read to join a network
const wifi = `WIFI:T:WPA;S:${ssid};P:${password};;`
return (
<div className="flex flex-wrap items-center justify-center gap-6">
<div className="grid w-60 gap-3 text-sm">
<label className="grid gap-1.5 font-medium">
Network name
<input value={ssid} onChange={(e) => setSsid(e.target.value)} className="h-9 rounded-md border border-input bg-background px-3 font-normal shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" />
</label>
<label className="grid gap-1.5 font-medium">
Password
<input value={password} onChange={(e) => setPassword(e.target.value)} className="h-9 rounded-md border border-input bg-background px-3 font-normal shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" />
</label>
<fieldset className="grid gap-1.5">
<legend className="font-medium">Error correction</legend>
<div className="flex gap-1.5">
{levels.map((l) => (
<button key={l} type="button" aria-pressed={level === l} onClick={() => setLevel(l)} className="h-8 flex-1 rounded-md border text-sm outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground">
{l}
</button>
))}
</div>
</fieldset>
</div>
<QRCode value={wifi} level={level} size={176} label={`QR code to join ${ssid}`} className="border" />
</div>
)
}Nothing to encode
import { QRCode } from "@/components/ballmac/qr-code"
export default function QRCodeEmpty() {
return <QRCode value="" size={200} />
}Too long
import { QRCode } from "@/components/ballmac/qr-code"
export default function QRCodeError() {
return <QRCode value={"https://example.com/a-link-that-is-far-too-long?".repeat(160)} size={200} />
}API reference
| Prop | Type | Default |
|---|---|---|
value*What the code holds: a web address, text, a Wi-Fi string or anything else. | string | — |
sizePixel size of the whole code, quiet zone included. | number | 192 |
levelHow much damage it survives: L 7%, M 15%, Q 25%, H 30%. More makes a denser code. A logo raises it to at least Q. | QRCodeLevel | "M" |
shapeShape of the dark modules. The three corner squares keep their own style so scanners find them. | QRCodeShape | "square" |
quietZoneEmpty modules around the code. Scanners need at least 4. | number | 4 |
foregroundDark color. Codes scan best dark on light, so the defaults stay that way in dark mode. Any CSS color. | string | "rgb(0, 0, 0)" |
backgroundLight color. | string | "rgb(255, 255, 255)" |
logoSomething drawn in the middle, such as a logo. Its area is cleared. | React.ReactNode | — |
logoSizeShare of the width the logo takes, up to 0.3. | number | 0.22 |
labelNames the code for screen readers. Defaults to "QR code for {value}". | string | — |
onErrorCalled when the value cannot be encoded. | (error: Error) => void | — |
svgRefThe SVG element, for downloads. | React.Ref<SVGSVGElement> | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | The code is an image named "QR code for …"; label replaces the name when the value is long or private |
| Always give a text alternative | Show the link or code as text near the picture, for people who cannot scan |
Quality scorecard
Every applicable check passes
Measured in Chromium 153.0.8010.12 on October 19, 2026, on version 1.0.0, across 6 examples.
Passed: Accessibility scan
No serious or critical issues in light and dark, on a phone and a desktop (axe-core 4.13.0, WCAG 2.1 AA and best-practice rules).
Passed: Small screens
No sideways scrolling at 360, 390 or 1280 px wide.
Passed: Reduced motion
No endless animation to begin with.
Passed: Right to left
Mirrors correctly in Arabic, Hebrew, Persian and Urdu layouts (0% of elements off the mirror, limit 15%).
Passed: Clean console
No console errors or React hydration errors while loading.
Passed: Keyboard
8 tab stops, each showing a visible change when it takes focus. 2 documented keys.
Passed: Loading, empty and error
Ships empty, error states, each with an example.
Passed: Automated tests
Covered by qr-code.test.tsx.
For information: Footprint
4.3 KB of source (gzip) in 1 file. Installs uqr.
Automated checks find a subset of problems and do not replace testing with a screen reader and a keyboard in your own app. What they miss.
Use with AI
<QRCode value="https://…" size={192} />. shape is square, rounded or dots; level is L, M, Q or H; logo is a node drawn in the middle. Use svgRef with downloadQRCode(svg, { format }) for a save button. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI QR Code (@ballmac/qr-code) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Sharing a link, a Wi-Fi network, a ticket or a pairing code to a phone
- Download or print screens that need a crisp code at any size
- A branded code with a logo in the middle
Not for
- Reading or scanning codes; this only draws them
- Very long text; shorten it first (a link shortener or an id)
- A barcode of another kind; this is QR only
Registry JSON: https://ui.ballmac.com/r/qr-code.json
Credits
Based on uqr, adapted by Ballmac. Free to use in personal and commercial projects.
- npm
- uqr@^0.1
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
Activity Feed
A compact actor and action feed with timestamps and an empty state.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.