Standard square QR codes are everywhere. But what if you want something that stands out? I built a circle QR code generator that wraps your brand message around the QR code itself—completely client-side, no server required.
Why Circle QR Codes?
A circle QR code isn't just a different shape. It's a complete visual reimagining. The circular frame with curved text creates something that looks less like a tech utility and more like a designed brand asset.
Think about where QR codes are actually used: product packaging, business cards, event posters, restaurant menus. A circle QR with "Call now" or "Visit our site" curved along the border does what a plain square code can't—it communicates while staying scannable.
The best part? It's still a real, scannable QR code. The underlying data encodes exactly like a standard QR. Only the visual presentation changed.
How the Circle QR Code Generator Works
The flow is similar to the square version, but with some interesting additions for circular rendering:
Base QR Generation with Circle Shape
The library handles the circular module pattern:
const qrCode = new QRCodeStyling({
width: size,
height: size,
type: "canvas",
shape: "circle", // The key difference from square
data,
image: imageUrl,
dotsOptions: {
color: dotColor,
type: "square", // Inner modules stay square for reliability
},
imageOptions: {
crossOrigin: "anonymous",
margin: 10,
hideBackgroundDots: true,
imageSize: Math.max(0.01, Math.min(1, logoSize / 100)),
},
});
This generates the QR with circular data modules—the dots themselves are arranged in rings rather than a square grid. It's a visual effect, but the encoding remains standard QR.
Circle Masking
After generating the QR, we apply a circular clip to ensure clean edges:
export function makeCircleFromCanvas(source: HTMLCanvasElement, bgColor?: string): HTMLCanvasElement {
const size = source.width;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d")!;
// Draw circular background if color is set
if (bgColor && bgColor !== "transparent") {
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = bgColor;
ctx.fill();
}
// Clip to circle
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
// Draw source inside the circle
ctx.drawImage(source, 0, 0, size, size);
return canvas;
}
The function creates a new canvas, optionally fills a circle background, then clips and draws the QR inside. Simple, but produces that clean circular badge look.
Curved Border Text — The Interesting Part
This is where it gets creative. Drawing text along a curve isn't built into canvas—here's how it's done:
export function addCircularTextToCanvas(
canvas: HTMLCanvasElement,
topText: string,
bottomText: string,
color: string,
fontSize: number,
bgColor?: string,
contentScale = 0.85
): HTMLCanvasElement {
const padding = Math.ceil(fontSize * 1.5);
const qrSize = canvas.width;
const newSize = qrSize + padding * 2;
const newCanvas = document.createElement("canvas");
newCanvas.width = newSize;
newCanvas.height = newSize;
const ctx = newCanvas.getContext("2d")!;
// Clear background
ctx.clearRect(0, 0, newSize, newSize);
// Draw circular background
if (bgColor && bgColor !== "transparent") {
ctx.beginPath();
ctx.arc(newSize / 2, newSize / 2, newSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.fillStyle = bgColor;
ctx.fill();
}
// Clip to circle
ctx.beginPath();
ctx.arc(newSize / 2, newSize / 2, newSize / 2, 0, Math.PI * 2);
ctx.closePath();
ctx.clip();
// Scale QR to fit inside with room for text
const scaledQrSize = qrSize * contentScale;
const offset = (newSize - scaledQrSize) / 2;
ctx.drawImage(canvas, offset, offset, scaledQrSize, scaledQrSize);
// Draw curved text
ctx.save();
ctx.translate(newSize / 2, newSize / 2);
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.fillStyle = color;
const radius = scaledQrSize / 2 + fontSize * 0.8;
if (topText) {
drawArcText(ctx, topText, radius, -Math.PI / 2, false); // Top text reads left-to-right
}
if (bottomText) {
drawArcText(ctx, bottomText, radius, Math.PI / 2, true); // Bottom text inverted
}
ctx.restore();
return newCanvas;
}
The algorithm:
- Create a larger canvas with padding for text
- Draw the circular background and clip to it
- Scale the QR code down slightly to make room
- Calculate a text radius (slightly outside the QR)
- Draw top text counterclockwise, bottom text clockwise
The Arc Text Math
This is the core curved text algorithm:
function drawArcText(
ctx: CanvasRenderingContext2D,
text: string,
radius: number,
centerAngle: number,
invert: boolean
) {
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// Split and measure each character
const chars = text.split("");
const widths = chars.map((c) => ctx.measureText(c).width);
const totalWidth = widths.reduce((a, b) => a + b, 0);
// Calculate starting angle based on text width
const direction = invert ? -1 : 1;
let currentAngle = centerAngle - direction * (totalWidth / 2) / radius;
// Draw each character rotated to follow the arc
for (let i = 0; i < chars.length; i++) {
const w = widths[i];
const angle = currentAngle + direction * (w / 2) / radius;
ctx.save();
ctx.translate(Math.cos(angle) * radius, Math.sin(angle) * radius);
ctx.rotate(angle + (invert ? -Math.PI / 2 : Math.PI / 2));
ctx.fillText(chars[i], 0, 0);
ctx.restore();
currentAngle += direction * (w / radius);
}
ctx.restore();
}
The key insight: instead of trying to draw curved text directly, rotate each character individually to follow the arc. For a character at angle θ on a circle of radius r, the rotation needed is θ + π/2 (for top) or θ - π/2 (for bottom).
User Controls
Similar to square QR, but with border text options:
// Circle border text inputs
const [circleBorderTopText, setCircleBorderTopText] = useState("");
const [circleBorderBottomText, setCircleBorderBottomText] = useState("");
const [circleBorderTextColor, setCircleBorderTextColor] = useState("#000000");
const [circleBorderTextSize, setCircleBorderTextSize] = useState(16);
Users can specify text for the top and bottom of the circle, customize color and size. Typical use cases:
- Top: brand name or promo ("SCAN ME")
- Bottom: website or phone ("example.com")
Real-World Usage
This tool shines in practical scenarios:
| Use Case | Top Text | Bottom Text |
|---|---|---|
| Restaurant menu | "MENU" | "Scan to order" |
| Business card | "CALL NOW" | "+1-555-0123" |
| Product packaging | "VIDEO" | "Watch demo" |
| Event flyer | "REGISTER" | "example.com/events" |
Batch Processing
Like the square version, supports multiple URLs at once:
const handleDownloadAll = async () => {
const zip = new JSZip();
qrCodes.forEach(({ url, name }) => {
const base64Data = url.replace(/^data:image\/png;base64,/, "");
zip.file(`${name}.png`, base64Data, { base64: true });
});
const content = await zip.generateAsync({ type: "blob" });
saveAs(content, "qrcodes.zip");
};
Perfect for printing shops or marketing teams generating dozens of QR codes at once.
Try It Yourself
Check out the live generator at Circle QR code generator. Upload your logo, add some border text, pick your colors—generates instantly in your browser.
Your content never goes to a server. What's entered stays on your device, processed entirely locally.
This article was originally published by DEV Community and written by monkeymore studio.
Read original article on DEV Community