Skip to content

QR code

A scannable code for a URL or secret, with a download action.

Status
Stable
Category
Content
Adoption
Not used yet
import { QRCode } from "@oration/canon/components/qr-code";
packages/canon/src/components/qr-code.tsx

Share Where is my payment

Anyone with the link can call the agent from a browser.

https://call.oration.app/cedarline/where-is-my-payment

Print it on supplier onboarding packets or remittance notices so suppliers can call from a phone camera.

import { Button } from "@oration/canon/components/button";import { CopyButton } from "@oration/canon/components/copy-button";import { QRCode } from "@oration/canon/components/qr-code";import { toast } from "@oration/canon/components/toast";import { ArrowUpRightIcon } from "lucide-react";import * as React from "react";export function Hero() {    const url = "https://call.oration.app/cedarline/where-is-my-payment";    const headingId = React.useId();    return (        <section            aria-labelledby={headingId}            className="flex w-full max-w-md flex-col gap-4 rounded-xl bg-popover p-4 shadow-lg"        >            <div className="flex flex-col gap-1">                <h2                    id={headingId}                    className="text-base leading-none font-medium text-foreground"                >                    Share Where is my payment                </h2>                <p className="text-sm text-muted-foreground">                    Anyone with the link can call the agent from a browser.                </p>            </div>            <div className="flex items-center gap-2 rounded-[10px] bg-muted/70 py-1 pr-1 pl-3">                <span className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">                    {url}                </span>                <CopyButton                    value={url}                    label="Copy link"                    onCopied={() => toast.add({ title: "Link copied" })}                />            </div>            <div className="flex flex-col items-center gap-4 sm:flex-row sm:items-start">                <QRCode                    value={url}                    size={120}                    label="QR code for the Where is my payment agent"                    downloadName="where-is-my-payment-qr"                />                <div className="flex min-w-0 flex-1 flex-col gap-2 text-13 text-muted-foreground">                    <p>                        Print it on supplier onboarding packets or remittance                        notices so suppliers can call from a phone camera.                    </p>                    <Button                        type="button"                        variant="ghost"                        size="sm"                        className="-ml-2 w-fit"                        onClick={() =>                            toast.add({                                title: "Opening the call page",                                description:                                    "call.oration.app opens in a new tab.",                            })                        }                    >                        Open call page                        <ArrowUpRightIcon                            data-icon="inline-end"                            aria-hidden="true"                        />                    </Button>                </div>            </div>        </section>    );}

Usage#

QR code renders a scannable code for a URL, phone number or setup secret on a white tile, with an optional PNG download. Oration uses it to share an agent's call page and to test the WhatsApp channel from a phone. The modules stay black on white in both themes, because many scanners fail on inverted codes. What people forget is the name: the default accessible label is just QR code, so always say what it opens.

When to use

  • To move a link from screen to phone: an agent's public call page, a WhatsApp chat, a supplier portal sign-in.
  • For a code people will print, such as on supplier onboarding packets or remittance notices, with downloadName.
  • To add an account to an authenticator app during two-step setup, beside the typed setup key.
  • Beside the same link as text with a copy button, never instead of it.

When not to use

  • As the only way to reach a link. Show the URL with a copy button too. Use Copy row
  • For a decorative identity mark. Use Identicon
  • For a code people read and type, such as a join code. Use One-time code

Black on white, always

The tile stays white and the modules black in dark mode too. Don't invert, tint or recolor a code; scanners expect dark modules on a light ground.

The Machine Mono Rule

The link or secret shown beside a code is a machine string, so it's Geist Mono. The caption explaining it is Geist Sans.

Anatomy#

  1. Tile. A white tile with 12px corners, 12px of padding and a 10% ink outline (white at 10% in dark), so it reads on any surface.
  2. Code. An SVG at size pixels with role="img" and your label as its name.
  3. Download. Optional with downloadName: an outline sm button that saves a 1024px PNG.

Examples#

Sizes

size sets the code's width in pixels; the white tile adds 12px of padding around it. 96 to 140px suits dialogs and setup steps, 160px is the default.

96px
120px
160px, default
import { QRCode } from "@oration/canon/components/qr-code";export function Sizes() {    const sizes = [96, 120, 160];    return (        <>            {sizes.map((size) => (                <figure key={size} className="flex flex-col items-center gap-2">                    <QRCode                        value="https://wa.me/13125550100?text=Where%20is%20my%20payment"                        size={size}                        label={`QR code for Cedarline on WhatsApp, ${size}px`}                    />                    <figcaption className="text-xs text-muted-foreground tabular-nums">                        {size === 160 ? "160px, default" : `${size}px`}                    </figcaption>                </figure>            ))}        </>    );}

With a download

downloadName adds an outline Download PNG button. The PNG is drawn separately at 1024px with a four-module quiet zone, so it prints cleanly.

Test it

Scan with your phone to open a chat with Cedarline on WhatsApp. The PNG is 1024px with the standard quiet zone, ready for print.

import { QRCode } from "@oration/canon/components/qr-code";export function WithDownload() {    return (        <div className="flex flex-col items-center gap-3 sm:flex-row sm:items-start sm:gap-6">            <QRCode                value="https://wa.me/13125550100?text=Where%20is%20my%20payment"                size={140}                label="QR code for Cedarline on WhatsApp"                downloadName="cedarline-whatsapp"            />            <div className="flex max-w-xs flex-col gap-1">                <p className="text-sm font-medium text-foreground">Test it</p>                <p className="text-13 text-muted-foreground">                    Scan with your phone to open a chat with Cedarline on                    WhatsApp. The PNG is 1024px with the standard quiet zone,                    ready for print.                </p>            </div>        </div>    );}

Error correction

level trades density for resilience. Higher levels survive smudges and folds on printed notices but pack more modules into the same size.

Level LRecovers about 7%
Level MAbout 15%, the default
Level HAbout 30%, for print
import { QRCode } from "@oration/canon/components/qr-code";export function ErrorCorrection() {    const levels = [        { level: "L" as const, note: "Recovers about 7%" },        { level: "M" as const, note: "About 15%, the default" },        { level: "H" as const, note: "About 30%, for print" },    ];    return (        <>            {levels.map((entry) => (                <figure                    key={entry.level}                    className="flex flex-col items-center gap-2"                >                    <QRCode                        value="https://call.oration.app/cedarline/where-is-my-payment"                        size={112}                        level={entry.level}                        label={`QR code for the Where is my payment agent, level ${entry.level}`}                    />                    <figcaption className="flex flex-col items-center text-xs">                        <span className="font-medium text-foreground">                            Level {entry.level}                        </span>                        <span className="text-muted-foreground">                            {entry.note}                        </span>                    </figcaption>                </figure>            ))}        </>    );}

Authenticator setup

An otpauth:// URI for two-step sign-in, with the same secret as a mono setup key people can copy and type instead.

Scan with your authenticator app

Or enter this key by hand.

JBSW Y3DP EHPK 3PXP
import { CopyButton } from "@oration/canon/components/copy-button";import { QRCode } from "@oration/canon/components/qr-code";import { toast } from "@oration/canon/components/toast";export function Authenticator() {    const secret = "JBSW Y3DP EHPK 3PXP";    const uri =        "otpauth://totp/Oration:maya.okafor@cedarline.io?secret=JBSWY3DPEHPK3PXP&issuer=Oration";    return (        <div className="flex w-full max-w-md flex-col gap-4 sm:flex-row sm:items-start">            <QRCode                value={uri}                size={128}                label="QR code to add Oration to your authenticator app"            />            <div className="flex min-w-0 flex-1 flex-col gap-3">                <div className="flex flex-col gap-1">                    <p className="text-sm font-medium text-foreground">                        Scan with your authenticator app                    </p>                    <p className="text-13 text-muted-foreground">                        Or enter this key by hand.                    </p>                </div>                <div className="flex items-center gap-2 rounded-[10px] bg-muted/70 py-1 pr-1 pl-3">                    <span className="min-w-0 flex-1 font-mono text-xs tracking-wide text-foreground">                        {secret}                    </span>                    <CopyButton                        value={secret.replaceAll(" ", "")}                        label="Copy setup key"                        onCopied={() =>                            toast.add({ title: "Setup key copied" })                        }                    />                </div>            </div>        </div>    );}

States#

States
StateTreatment
DefaultThe tile and code, at 160px unless size says otherwise.
With downloadThe tile, then a 12px gap and the Download PNG button, centered under it.
Dark themeUnchanged: a white tile with black modules. Only the tile's outline turns to white at 10%.

Behavior#

  • The code updates whenever value changes; there's no loading state.
  • The visible code is an SVG, so it stays sharp at any size and zoom.
  • With downloadName, a hidden 1024px canvas renders the same value with a four-module quiet zone. The button saves it as {downloadName}.png, adding the extension if you left it off.
  • level sets error correction: L recovers about 7% of a damaged code, M about 15% (the default), Q about 25% and H about 30%. Higher levels mean more, smaller modules at the same size.
  • The on-screen tile's 12px padding is the quiet zone. Keep other content from crowding the tile's edge.

Do and don't#

Do. Name the code for what it opens: QR code to call the Cedarline supplier line.
Don't. Leave the default label. Screen reader users hear QR code, image and learn nothing.
Do. Keep the white tile and black modules in dark mode.
Don't. Invert the code to match a dark theme. Many phone scanners won't read light modules on a dark ground.

Content#

  • Put one sentence beside the code that says what scanning does: Scan with your phone to open a chat with Cedarline on WhatsApp.
  • Show the same link or key as text, in Geist Mono, with a copy button.
  • Name downloads for what they are: cedarline-whatsapp, where-is-my-payment-qr.
  • Keep the default button label, Download PNG, unless the format changes.

Accessibility#

  • The SVG is role="img" with aria-label from label. Name the destination: QR code for Cedarline on WhatsApp.
  • A code is unusable without a camera. Always offer the same link or secret as selectable text beside it.
  • The hidden download canvas is aria-hidden and display: none, so it's never announced.
  • The download button's icon is decorative; the label names the action.
Keyboard interactions
KeysAction
TabMoves to the download button, if there is one.
EnterDownloads the PNG.

Design tokens#

Design tokens
TokenUsed for
bg-whiteThe tile, fixed in both themes
outline-black/10The tile edge in light
outline-white/10The tile edge in dark
--radius-xl12px tile corners

API reference#

QRCode

A QR code tile with an optional download. Takes only the props below.

Props of QRCode
PropTypeDefaultDescription
valueRequiredstringNo defaultWhat the code encodes: a URL, tel: number or otpauth:// URI.
sizenumber160Width and height of the code in pixels, before the tile's padding.
labelstring"QR code"Accessible name of the code image.
downloadNamestringNo defaultFile name for the PNG. Setting it shows the download button.
downloadLabelstring"Download PNG"The download button's label.
level"L" | "M" | "Q" | "H""M"Error correction level.
classNamestringNo defaultMerged onto the outer column.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The tile draws its edge with a CSS outline at 10% ink rather than the hairline lift. That's deliberate so the tile stays white in dark, but it's a different edge from every other raised surface.

The on-screen code has no margin of its own; its quiet zone is the tile's 12px padding, two to three modules for a typical link at the default size, where the QR spec asks for four. The downloaded PNG has the full four.

The default label is QR code. It would be safer as a required prop.

There's no feedback after a download; the browser's own download UI is the only confirmation.