velvet-qr
v1.5.1
Published
The easiest, most customizable, developer-friendly QR code component for React. Gradients, dot & eye styles, logos, and PNG/SVG/JPEG export — built for React 18 & 19.
Maintainers
Readme
Velvet QR
Generate beautiful QR codes effortlessly.
Part of the Velvet ecosystem of React developer libraries — velvet-qr is the easiest, most customizable, developer-friendly QR code component for React. Gradients, dot & eye styles, logos, and PNG/SVG/JPEG export, all fully typed.
Features
- 🎨 Fully customizable — colors, gradients, dot styles, eye styles, rounded corners, transparent backgrounds
- 🧵 Connected dot rendering — "rounded"/"dots"/"classy"/"classy-rounded"/"extra-rounded" styles merge adjacent modules into a smooth connected shape, not isolated blobs
- 🖼️ Logo embedding — center a logo with configurable size, padding, and border radius, with automatic module excavation for scannability
- 🧩 Presets —
QR_PRESETS+QRPresetPickerfor ready-made styles (gradient "Velvet" look, dark-mode "Midnight", and more) - 🗂️ Batch generation —
useQRBatch/QRBatchGeneratorfor generating and bulk-downloading many QR codes at once - 🏷️ Frame / label templates —
frameprop bakes a "SCAN ME"-style banner or bordered label directly into the exported SVG/PNG/JPEG, so it survives print/flat-image sharing - 📷 QR scanner —
useQRScanner/QRScannerdecode QR codes from a live camera or a static image, using the browser-nativeBarcodeDetectorAPI where available and transparently falling back to the bundledjsQRdecoder everywhere else — works in Chrome, Edge, Firefox, and Safari alike - 🕓 Local QR history —
useQRHistory/QRHistoryPanelremember recently generated configs inlocalStoragefor quick re-use - 📊 Opt-in local analytics —
useQRAnalyticstracks generate/download/copy/in-session-scan events you observe in your own page — see the honesty note below, it cannot see real-world camera scans - 🔗 Dynamic QR helpers —
buildDynamicQrValue/useDynamicQrhelp you build a QR that points at a stable URL under your own redirect endpoint, so the destination can change after printing (requires your own backend — see below) - 📦 Built-in content builders — URL, plain text, email, phone, SMS, WiFi, vCard, and Google Maps/geo location
- ⬇️ One-line exports — download as PNG, SVG, or JPEG, or copy the image straight to the clipboard
- 🪝 Hooks-first API —
useQRGenerator,useQRDownload, anduseQRBatchfor full control when you don't want the batteries-included components - 🔒 Strongly typed — complete TypeScript definitions, no
any - ⚡ Tiny & tree-shakeable — two runtime dependencies (
qrcodefor matrix encoding,jsqrfor the scanner's cross-browser fallback decoding), ESM + CJS builds,"use client"banner for Next.js App Router - ♿ Accessible — proper
role="img"/aria-labelon generated SVGs, including an accessible placeholder ifvalueis empty or generation fails
Installation
npm install velvet-qr
# or
yarn add velvet-qr
# or
pnpm add velvet-qrRequires React 18 or 19.
Quick Start
import { VelvetQR } from "velvet-qr";
export default function Example() {
return (
<VelvetQR
value="https://poojagohel.github.io/velvet-qr/"
size={250}
foregroundColor="#000"
backgroundColor="#fff"
/>
);
}Downloading & copying
VelvetQR exposes an imperative handle via ref — no extra wiring required:
import { useRef } from "react";
import { VelvetQR, type VelvetQRHandle } from "velvet-qr";
function DownloadableQR() {
const qrRef = useRef<VelvetQRHandle>(null);
return (
<>
<VelvetQR ref={qrRef} value="https://poojagohel.github.io/velvet-qr/" size={250} />
<button onClick={() => qrRef.current?.downloadPNG()}>Download PNG</button>
<button onClick={() => qrRef.current?.downloadSVG()}>Download SVG</button>
<button onClick={() => qrRef.current?.downloadJPEG()}>Download JPEG</button>
<button onClick={() => qrRef.current?.copyToClipboard()}>Copy image</button>
</>
);
}Or use the ready-made toolbar:
import { useRef } from "react";
import { VelvetQR, QRDownloader, type VelvetQRHandle } from "velvet-qr";
function Example() {
const qrRef = useRef<VelvetQRHandle>(null);
return (
<>
<VelvetQR ref={qrRef} value="https://poojagohel.github.io/velvet-qr/" />
<QRDownloader qrRef={qrRef} />
</>
);
}Gradients, dot styles, and a logo
<VelvetQR
value="https://poojagohel.github.io/velvet-qr/"
size={280}
dotStyle="extra-rounded"
eyeStyle="leaf"
cornerRadius={24}
gradient={{ type: "linear", colors: ["#7c3aed", "#ec4899"], rotation: 45 }}
logoImage="/logo.png"
logoSize={0.22}
logoPadding={6}
logoBorderRadius={12}
/>Content builders
Every common QR use case has a small, pure helper that returns the correctly formatted string for value:
import {
VelvetQR,
buildWifiValue,
buildVCardValue,
buildEmailValue,
buildSmsValue,
buildPhoneValue,
buildLocationValue,
buildGoogleMapsValue,
} from "velvet-qr";
// WiFi
<VelvetQR value={buildWifiValue({ ssid: "Velvet Cafe", password: "s3cret!", encryption: "WPA" })} />
// vCard / business card
<VelvetQR
value={buildVCardValue({
firstName: "Pooja",
lastName: "Gohel",
org: "Velvet UI",
title: "Founder",
phone: "+1 555 0100",
email: "[email protected]",
website: "https://poojagohel.github.io/velvet-qr/",
})}
/>
// Email, SMS, phone, location
<VelvetQR value={buildEmailValue({ to: "[email protected]", subject: "Hi!" })} />
<VelvetQR value={buildSmsValue({ phone: "+15550100", message: "Hello!" })} />
<VelvetQR value={buildPhoneValue("+15550100")} />
<VelvetQR value={buildLocationValue({ latitude: 37.7749, longitude: -122.4194 })} />
<VelvetQR value={buildGoogleMapsValue({ query: "Golden Gate Bridge" })} />The customization panel
import { useState } from "react";
import { VelvetQR, QRCustomizer, type QRCustomizerConfig } from "velvet-qr";
function Playground() {
const [config, setConfig] = useState<QRCustomizerConfig>({ value: "https://poojagohel.github.io/velvet-qr/" });
return (
<>
<VelvetQR {...config} />
<QRCustomizer value={config} onChange={setConfig} />
</>
);
}Logo uploader
import { useState } from "react";
import { VelvetQR, QRLogoUploader } from "velvet-qr";
function WithLogo() {
const [logo, setLogo] = useState<string | null>(null);
return (
<>
<VelvetQR value="https://poojagohel.github.io/velvet-qr/" logoImage={logo ?? undefined} />
<QRLogoUploader onLogoChange={setLogo} />
</>
);
}Presets
Skip the styling decisions entirely with a curated set of ready-made looks:
import { useState } from "react";
import { VelvetQR, QRPresetPicker, type VelvetQRProps } from "velvet-qr";
function PresetDemo() {
const [config, setConfig] = useState<Partial<VelvetQRProps>>({});
return (
<>
<VelvetQR value="https://poojagohel.github.io/velvet-qr/" {...config} />
<QRPresetPicker onSelect={setConfig} />
</>
);
}Or apply one directly without the picker UI:
import { VelvetQR, QR_PRESETS } from "velvet-qr";
const velvet = QR_PRESETS.find((p) => p.id === "velvet")!;
<VelvetQR value="https://poojagohel.github.io/velvet-qr/" {...velvet.config} />;Batch generation
Generate and bulk-download many QR codes at once:
import { QRBatchGenerator } from "velvet-qr";
function Batch() {
return (
<QRBatchGenerator
items={[
{ id: "table-1", value: "https://poojagohel.github.io/velvet-qr/menu?table=1" },
{ id: "table-2", value: "https://poojagohel.github.io/velvet-qr/menu?table=2" },
{ id: "table-3", value: "https://poojagohel.github.io/velvet-qr/menu?table=3" },
]}
sharedOptions={{ dotStyle: "rounded", eyeStyle: "circle" }}
/>
);
}Or drive it yourself with the underlying hook:
import { useQRBatch } from "velvet-qr";
function CustomBatch() {
const { results, downloadAll } = useQRBatch(
[{ id: "a", value: "https://a.example" }, { id: "b", value: "https://b.example" }],
);
// render results.map(r => r.svgMarkup) however you like
// downloadAll('png' | 'svg' | 'jpeg') triggers one sequential download per item
}Hooks — full control
import { useQRGenerator, useQRDownload } from "velvet-qr";
function Custom() {
const { svgMarkup, getDataUrl, isGenerating, error } = useQRGenerator({
value: "https://poojagohel.github.io/velvet-qr/",
size: 240,
});
// render svgMarkup however you like, or call getDataUrl('png' | 'jpeg' | 'svg')
}Frame / label templates
Bake a "SCAN ME" caption directly into the exported image — it's part of the generated SVG (not a separate on-screen wrapper), so it's included in PNG/SVG/JPEG downloads too:
<VelvetQR
value="https://poojagohel.github.io/velvet-qr/"
size={240}
frame={{ style: "banner-bottom", text: "SCAN ME" }}
/>frame.style is one of "none" (default), "banner-top", "banner-bottom", or
"border-label". "banner-top"/"banner-bottom" grow the rendered SVG to
size wide × size + size * 0.2 tall (the caption band is ~20% of size); "border-label"
keeps the SVG at size × size and instead draws a border with the label in a small pill
straddling its bottom edge. Because banner styles change the overall height, always read the
actual viewBox/width/height off the generated markup (or the size VelvetQR's own
downloadPNG/downloadJPEG/getDataUrl methods rasterize to) rather than assuming a square
size x size canvas when doing layout. Keep frame.text to roughly 20 characters or fewer
(14 or fewer for border-label) for reliably legible results — longer text is automatically
shrunk and, if needed, truncated with an ellipsis.
<VelvetQR
value="https://poojagohel.github.io/velvet-qr/"
size={240}
frame={{
style: "border-label",
text: "SCAN ME",
backgroundColor: "#7c3aed",
textColor: "#ffffff",
cornerRadius: 16,
}}
/>Animation
Add a purely decorative, on-screen-only reveal or "alive" effect for a landing-page hero —
it never affects scannability, and exports (PNG/SVG/JPEG downloads, getDataUrl, and batch
downloadAll) are always the fully-settled, static QR regardless of animation:
<VelvetQR
value="https://poojagohel.github.io/velvet-qr/"
size={240}
animation={{ type: "fade-in" }} // "fade-in" | "draw-in" | "pulse" | "gradient-shift"
/>"fade-in"/"draw-in": a one-shot staggered reveal (data modules fade in, or fade+scale in) that plays once and settles at fully visible forever after."pulse": a continuous, gentle looping "breathing" effect for hero sections — never applied to alogoImageif one is set, so the logo stays static/legible."gradient-shift": continuously rotates agradient(no-ops back to"none"ifgradientisn't also set).
All animation types respect prefers-reduced-motion: reduce, falling back to the static,
fully-settled render. Tune timing with duration (seconds) and, for the reveal types,
staggerDelay (seconds of extra delay per module):
<VelvetQR value="https://poojagohel.github.io/velvet-qr/" animation={{ type: "draw-in", duration: 1.5, staggerDelay: 0.004 }} />Scanner
Decode QR codes from a live camera or a static image. Uses the browser-native
BarcodeDetector API where available (Chrome/Edge) and transparently falls back to the
bundled jsQR decoder everywhere else (Firefox, Safari, and any other browser), so scanning
works across all major browsers:
import { QRScanner } from "velvet-qr";
function Scan() {
return (
<QRScanner
onDetected={(value) => console.log("Scanned:", value)}
continuous={false}
/>
);
}Or drive it yourself with the underlying hook:
import { useRef } from "react";
import { useQRScanner } from "velvet-qr";
function CustomScanner() {
const { isSupported, isScanning, result, error, startCamera, stopCamera, scanImage } = useQRScanner();
const videoRef = useRef<HTMLVideoElement>(null);
if (!isSupported) return <p>Camera scanning isn't supported in this browser.</p>;
return (
<>
<video ref={videoRef} muted playsInline />
<button onClick={() => videoRef.current && startCamera(videoRef.current)}>Start</button>
<button onClick={stopCamera}>Stop</button>
<input type="file" accept="image/*" onChange={(e) => e.target.files?.[0] && scanImage(e.target.files[0])} />
{result && <p>Scanned: {result}</p>}
{error && <p>Error: {error}</p>}
</>
);
}isSupported is false (rather than throwing) only when neither BarcodeDetector nor the
jsQR canvas fallback can run at all — in practice that's just non-browser/SSR
environments. Every real browser reports true. Still always render a fallback (e.g. the
QRScanner component's built-in file-input path) for the rare environments where it isn't.
History
useQRHistory keeps a small local record of recently generated/used QR configurations in
localStorage (SSR-safe, capped at maxEntries), and QRHistoryPanel renders it as a row of
live, clickable thumbnails:
import { useState } from "react";
import { VelvetQR, useQRHistory, QRHistoryPanel, type VelvetQRProps } from "velvet-qr";
function HistoryDemo() {
const [config, setConfig] = useState<Partial<VelvetQRProps>>({});
const [value, setValue] = useState("https://poojagohel.github.io/velvet-qr/");
const history = useQRHistory({ maxEntries: 20 });
return (
<>
<VelvetQR value={value} {...config} />
<button onClick={() => history.add({ value, config })}>Save to history</button>
<QRHistoryPanel
history={history}
onSelect={(entry) => {
setValue(entry.value);
setConfig(entry.config);
}}
/>
</>
);
}This is purely local bookkeeping of configs you explicitly add() — it has no relationship
to whether the resulting QR code has ever been scanned by anyone.
Analytics — honestly scoped
This package is a client-side React component library with no backend and no hosting. It is architecturally impossible for it to detect when someone scans a printed or shared QR code with their own phone camera — that scan happens entirely outside any page this library's JS ever runs in.
useQRAnalyticsonly tracks events observable within your own page session: generation, exports (PNG/SVG/JPEG), clipboard copies, and in-session decodes if you also use this package's ownuseQRScanner/QRScanner. Real "was my printed QR code scanned" tracking requires server-side infrastructure outside this package's scope — e.g. routing the QR's destination through your own backend redirect endpoint and logging hits there (see Dynamic QR below).
useQRAnalytics does not wrap or auto-instrument VelvetQR — you call track() explicitly,
wherever you already have the relevant information:
import { useRef } from "react";
import { VelvetQR, useQRAnalytics, type VelvetQRHandle } from "velvet-qr";
function AnalyticsDemo() {
const qrRef = useRef<VelvetQRHandle>(null);
const { track, stats } = useQRAnalytics({
persist: true, // aggregate local counts into localStorage
onTrack: (event) => myAnalyticsBackend.send(event), // pipe to GA/PostHog/your own backend
});
return (
<>
<VelvetQR
ref={qrRef}
value="https://poojagohel.github.io/velvet-qr/"
onGenerated={() => track({ type: "generate", value: "https://poojagohel.github.io/velvet-qr/" })}
/>
<button
onClick={async () => {
await qrRef.current?.downloadPNG();
track({ type: "download", value: "https://poojagohel.github.io/velvet-qr/", format: "png" });
}}
>
Download PNG
</button>
<p>Generated locally {stats.byType.generate} times this session/device.</p>
</>
);
}Dynamic QR — honestly scoped
A "dynamic QR" (one whose destination can change after printing) needs the printed code to
always point at a stable URL your own backend controls, so the backend — not this
package — decides where to redirect at scan time. buildDynamicQrValue builds that stable
URL:
import { VelvetQR, buildDynamicQrValue } from "velvet-qr";
<VelvetQR value={buildDynamicQrValue({ baseUrl: "https://yourapp.com/r", id: "table-12" })} />;
// -> encodes "https://yourapp.com/r/table-12" — your server's /r/table-12 route decides
// where to redirect, and can change that destination at any time without reprinting the code.useDynamicQr is a small convenience wrapper around that, with a localStorage-backed
resolver meant only for local prototyping/demos of the "swap the destination" experience —
it does not make the physical/printed code dynamic for anyone else scanning it with a real
camera, since that requires a real server-side redirect endpoint:
import { VelvetQR, useDynamicQr } from "velvet-qr";
function DynamicQrDemo() {
const { value, destination, isLoading, setDestination } = useDynamicQr({
id: "table-12",
baseUrl: "https://yourapp.com/r",
// resolver: (id) => fetch(`/api/dynamic-links/${id}`).then((r) => r.json()).then((d) => d.destination),
});
return (
<>
<VelvetQR value={value} />
<p>{isLoading ? "Loading…" : `Currently redirects to: ${destination ?? "(unset)"}`}</p>
<button onClick={() => setDestination("https://example.com/new-menu")}>
Update destination (localStorage demo only)
</button>
</>
);
}Pass your own resolver (backed by a real API) for production use — setDestination is a
no-op (with a console.warn) when a custom resolver is supplied, since updating the real
destination is your backend's job, not this hook's.
Props
VelvetQR
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| value | string | — | Required. The content to encode. Use the builder helpers for structured data (WiFi, vCard, etc). |
| size | number | 240 | Rendered width/height in pixels. Scales down responsively (max-width: 100%). Clamped to > 0. |
| margin | number | 4 | Quiet-zone size, in QR modules. Clamped to >= 0. |
| backgroundColor | string | "#ffffff" | Background fill color. Ignored if transparentBackground is set. |
| foregroundColor | string | "#000000" | Data module color. Ignored if gradient is set. |
| errorCorrectionLevel | "L" \| "M" \| "Q" \| "H" | "M" | Higher levels tolerate more damage/logo overlap at the cost of density. Use "H" when embedding a logo. |
| cornerRadius | number | 0 | Corner radius (px) of the outer background rect. |
| dotStyle | "square" \| "rounded" \| "dots" \| "classy" \| "classy-rounded" \| "extra-rounded" | "square" | Shape of the data modules. |
| eyeStyle | "square" \| "circle" \| "rounded" \| "leaf" | "square" | Shape of the three finder-pattern "eyes". |
| eyeColor | string | foregroundColor | Override color for the eyes only. |
| gradient | { type: "linear" \| "radial"; colors: [string, string]; rotation?: number } | — | Applies a gradient fill to data modules instead of foregroundColor. |
| transparentBackground | boolean | false | Omit the background rect entirely (transparent PNG/SVG exports). |
| logoImage | string | — | URL or data URL of a logo to embed at the center. |
| logoSize | number | 0.2 | Logo size as a fraction (0–1) of size. Clamped to [0, 1]. |
| logoPadding | number | 4 | Solid-color padding (px) around the logo. |
| logoBorderRadius | number | 8 | Corner radius (px) of the logo's padded backdrop. |
| logoExcavate | boolean | true | Skip rendering data modules directly under the logo so it stays legible. |
| frame | { style: "none" \| "banner-top" \| "banner-bottom" \| "border-label"; text?: string; backgroundColor?: string; textColor?: string; cornerRadius?: number } | — ("none") | Bakes a caption banner/border-label into the exported SVG/PNG/JPEG. See "Frame / label templates" above for the exact dimension contract. |
| animation | { type: "none" \| "fade-in" \| "draw-in" \| "pulse" \| "gradient-shift"; duration?: number; staggerDelay?: number } | — ("none") | Purely decorative, on-screen-only animation. See "Animation" above. Exports (downloadPNG/downloadSVG/downloadJPEG/getDataUrl, and useQRBatch's downloadAll) are always the fully-settled, static QR, regardless of this prop. |
| className / style | — | — | Passed through to the root <svg>. |
| ariaLabel | string | `QR code for ${value}` | Accessible label on the root <svg> (role="img"). |
| onGenerated | (svgMarkup: string) => void | — | Called whenever the generated SVG markup changes. |
| id | string | auto-generated | Root element id; also used internally for gradient/clip-path def scoping. |
VelvetQRHandle (ref)
| Method | Description |
| --- | --- |
| downloadPNG(filename?) | Rasterizes the current SVG and downloads it as a PNG. |
| downloadSVG(filename?) | Downloads the raw SVG markup. |
| downloadJPEG(filename?, quality?) | Rasterizes and downloads as JPEG (quality 0–1, default 0.92). |
| copyToClipboard() | Copies the rendered PNG to the system clipboard via the Clipboard API. |
| getDataUrl(format, quality?) | Returns a data URL ("png" \| "jpeg" \| "svg") without triggering a download. |
Components
| Component | Purpose |
| --- | --- |
| VelvetQR | The core QR renderer. |
| QRPreview | Styled preview card wrapping VelvetQR. |
| QRDownloader | Ready-made download/copy toolbar bound to a VelvetQR ref. |
| QRCustomizer | Controlled customization panel (value + onChange) for building playgrounds/editors. |
| QRLogoUploader | Drag-and-drop / file-picker input that resolves an image file to a data URL. |
| QRPresetPicker | Row of live, clickable preset thumbnails calling onSelect(config). |
| QRBatchGenerator | Grid of generated QR codes for a list of items, plus a "Download All" action. |
| QRScanner | Camera + static-image QR scanner UI built on useQRScanner, calling onDetected(value). |
| QRHistoryPanel | Row of live thumbnails for useQRHistory entries, calling onSelect(entry), with per-entry remove. |
Hooks
| Hook | Purpose |
| --- | --- |
| useQRGenerator(options) | Core generation hook: matrix + SVG markup + getDataUrl, without any component/DOM ref. |
| useQRDownload(qrRef) | Wraps a VelvetQR ref with downloadPNG/downloadSVG/downloadJPEG/copyToClipboard. |
| useQRBatch(items, sharedOptions?) | Generates { id, value, svgMarkup, error } for each item, plus downloadAll(format). |
| useQRScanner() | Decodes QR codes from a camera stream (startCamera/stopCamera) or a static image (scanImage) via the native BarcodeDetector API. isSupported is false when unavailable. |
| useQRHistory(options?) | Local, localStorage-backed history of QR configs: entries, add, remove, clear, restore. |
| useQRAnalytics(options?) | Opt-in, explicitly-called event tracking (track) for generate/download/copy/scan-decode events observable in your own session — not real-world camera scans. See "Analytics — honestly scoped" above. |
| useDynamicQr(options) | Builds a stable dynamic-QR value plus a destination/setDestination/refresh API; default resolver is localStorage-backed for prototyping only. See "Dynamic QR — honestly scoped" above. |
Presets
| Export | Purpose |
| --- | --- |
| QR_PRESETS | 8 curated { id, name, description, config } presets — spread .config onto <VelvetQR>. |
| QrPreset | The preset type, for building your own preset list in the same shape. |
FAQ
SVG, PNG, or JPEG — which should I export? SVG for print/scalable use (logos, packaging); PNG when you need a transparent background or a raster file for the web; JPEG only if file size matters more than a transparent background (JPEG has no alpha channel).
Will a logo make my QR code unscannable? Keep logoSize under ~0.25 and use errorCorrectionLevel="H" (or at least "Q") when embedding a logo — the extra error-correction data is what keeps the code scannable despite the excavated center.
Does this work with Next.js / SSR? Yes — the package ships a "use client" banner, so import VelvetQR directly in a Client Component. See examples/nextjs-app-router in the repo for a full walkthrough of why your own page still needs its own "use client" directive too.
How big is the bundle? velvet-qr has two runtime dependencies: qrcode (used only to compute the encoding matrix — all rendering is a hand-written SVG engine) and jsqr (~15KB, used only as the scanner's cross-browser fallback decoder when the native BarcodeDetector API isn't available). The whole package tree-shakes per export.
Can velvet-qr track when my QR code gets scanned? No — not a real-world scan of a printed or shared code. velvet-qr is a client-side component library with no backend and no hosting, so it never runs any code on the device that scans your printed QR — there's nothing for it to observe. useQRAnalytics can track events that happen inside your own page, in your own visitor's session (QR generation, PNG/SVG/JPEG exports, clipboard copies, and in-session decodes if you also use useQRScanner), but that's a different thing from "someone scanned my flyer." Real scan tracking requires your own server: point the QR at a stable URL under your own redirect endpoint (buildDynamicQrValue/useDynamicQr) and log hits there.
For deeper coverage (batch generation, presets, logo scannability tuning, error-correction-level tradeoffs), see docs/FAQ.md in the repo root, alongside docs/INSTALLATION.md, docs/API-REFERENCE.md, and docs/MIGRATION.md.
License
MIT © Pooja Gohel
