/**
* QRCode — a string, drawn as something a camera can read.
*
* ```tsx
*
*
*
*
* Scan to open the docs
*
* ```
*
* Composition is the API, as everywhere else here: a code in a titled panel is
* one with a `QRCode.Header` in it, and a code behind a button is one wrapped
* in a `QRCode.Trigger` and a `QRCode.Content` — not one with a prop turned on.
*
* ## What it draws
*
* Three ``s: the body, the three corner rings, and the three corner
* centres. Every module is a subpath of one of them, not a `` of its
* own. A version 10 code is 3,481 modules; half of them dark is seventeen
* hundred native views for a picture that never changes, and the difference
* between that and three nodes is the difference between a code that appears
* and one that appears eventually.
*
* Three rather than one because they are the three things worth colouring and
* shaping separately. A code is found by its corner eyes before a single
* module is read, so they are the part a design touches first — and the part
* it is most dangerous to touch badly.
*
* The colours come from the theme, so a code drawn on a card is legible on
* every one of them — and light-on-dark is drawn the way scanners expect it,
* with the quiet zone painted rather than left transparent. A code with
* nothing behind it reads at about half the distance.
*
* ## Shaping it
*
* `moduleShape`, `eyeFrameShape` and `eyeBallShape` on `QRCode.Canvas` change
* the geometry; `color`, `eyeFrameColor`, `eyeBallColor` and `backgroundColor`
* change the ink. A scanner samples the centre of each cell, so every shape
* here reads exactly as a square does — what changes is how much of the cell
* is covered. `dot` fills about two thirds of it and `diamond` half, which is
* read distance spent on a look. Raise `errorCorrection` to pay for it, and
* check a printed code rather than one on a screen.
*
* ## The hole in the middle
*
* `QRCode.Logo` clears a square of modules and puts its children there.
* Error correction is what makes that survivable — the modules are gone, and
* the code still reads because there is enough redundancy to reconstruct them.
* How much is `errorCorrection`, and a logo raises the level it needs: the
* default `M` tolerates about 15% loss, `H` about 30%. A logo is measured
* against that budget and the level is raised for you if it has to be.
*/
import {
createContext,
forwardRef,
useContext,
useMemo,
type ReactElement,
type ReactNode,
} from 'react';
import { View, type ViewProps } from 'react-native';
import Svg, { Path, Rect } from 'react-native-svg';
import { tv, type VariantProps } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { Text } from '../../primitives/text';
import { cn } from '../../utils/cn';
import { Popover, type PopoverContentProps } from '../popover';
import { encodeQr, type ErrorCorrectionLevel, type QrMatrix } from './qr-encode';
import {
eyeBallPath,
eyeFramePath,
finderOrigins,
inFinder,
modulePath,
type QRCodeEyeBallShape,
type QRCodeEyeFrameShape,
type QRCodeModuleShape,
} from './qr-shapes';
export type { ErrorCorrectionLevel };
export type { QRCodeModuleShape, QRCodeEyeFrameShape, QRCodeEyeBallShape };
const qrCodeVariants = tv({
slots: {
root: 'items-center gap-3',
// The widget shell every chart in the library is shown in: a titled tray
// with the card flush inside it. A code is the same kind of thing — one
// object with a label over it — so it is the same shape.
frame: 'w-full overflow-hidden rounded-3xl border border-border bg-surface',
header: 'w-full flex-row items-center justify-between gap-3 px-4 pb-3 pt-2.5',
// Flush left, right and bottom: the shell's own edge is already there.
panel: 'relative items-center justify-center overflow-hidden rounded-t-2xl border-t border-border bg-card p-4',
caption: 'text-center text-sm text-muted-foreground',
value: 'text-center text-xs text-muted-foreground',
},
variants: {
size: {
sm: {},
md: {},
lg: {},
},
},
defaultVariants: {
size: 'md',
},
});
type QrCodeVariantProps = VariantProps;
/** How big the drawn code is. */
export type QRCodeSize = NonNullable;
/** Side length in points per size. The module grid divides into it. */
const CANVAS_SIZE: Record = { sm: 128, md: 180, lg: 240 };
/**
* The quiet zone, in modules. Four is what the specification asks for and what
* scanners are tuned to; less and a code against a busy background stops being
* found at all.
*/
const QUIET_ZONE = 4;
/** Roughly how much of the code each level can lose and still be read. */
const TOLERANCE: Record = { L: 0.07, M: 0.15, Q: 0.25, H: 0.3 };
/** Ascending, so "the next level up" is a step through this. */
const LEVELS: ErrorCorrectionLevel[] = ['L', 'M', 'Q', 'H'];
/** How much of the code a logo of this fraction covers. */
function coverage(logoFraction: number) {
return logoFraction * logoFraction;
}
/**
* The cleared square, in module coordinates.
*
* Both the canvas and the logo compute it from this, so the hole and the thing
* filling it are the same square. Two nearly-equal calculations is how you get
* a dark ring around a logo, which is the failure the screenshot showed.
*/
function holeBounds(matrixSize: number, logoFraction: number) {
const from = Math.floor((matrixSize * (1 - logoFraction)) / 2);
const to = Math.ceil((matrixSize * (1 + logoFraction)) / 2);
return { from, to };
}
/**
* The level actually used: the one asked for, unless a logo would eat more of
* the code than it can afford, in which case the next one that can.
*/
function levelFor(requested: ErrorCorrectionLevel, logoFraction: number): ErrorCorrectionLevel {
const needed = coverage(logoFraction);
// Half the budget, not all of it: the rest is for the things a code in the
// world actually loses — glare, a fold, a thumb.
const affordable = (level: ErrorCorrectionLevel) => TOLERANCE[level] / 2 >= needed;
if (affordable(requested)) return requested;
return LEVELS.slice(LEVELS.indexOf(requested)).find(affordable) ?? 'H';
}
interface QRCodeContextValue {
matrix: QrMatrix | null;
/** What went wrong, when there is no matrix. */
error: string | null;
value: string;
size: QRCodeSize;
/** Side of the logo hole as a fraction of the code, `0` for none. */
logoFraction: number;
slots: ReturnType;
}
const QRCodeContext = createContext(null);
function useQRCode(part: string) {
const context = useContext(QRCodeContext);
if (!context) throw new Error(`${part} must be used inside .`);
return context;
}
/** Where the code is drawn: in place, in a popover, or up from the bottom. */
export type QRCodePresentation = 'inline' | 'popover' | 'bottom-sheet';
export interface QRCodeProps extends Omit, QrCodeVariantProps {
className?: string;
/**
* What the code encodes. Anything: a URL, a WiFi string, a vCard. Encoded
* as UTF-8, and the version grows to fit it.
*/
value: string;
/**
* How much of the code can be lost and still read — `L` about 7%, `M` 15%,
* `Q` 25%, `H` 30%. More correction means a denser code at the same size, so
* `M` is the default. Raised automatically when a `QRCode.Logo` needs it.
*/
errorCorrection?: ErrorCorrectionLevel;
/**
* Fix the QR version, 1–40, instead of taking the smallest that fits. Worth
* setting when the content changes and the code should not visibly change
* density with it.
*/
version?: number;
/**
* Where the code appears. `inline` draws it where it sits; the other two
* put it behind a `QRCode.Trigger` and draw it in a `QRCode.Content`.
*/
presentation?: QRCodePresentation;
/** Controlled open state. Ignored while `presentation` is `inline`. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: ReactNode;
}
const QRCodeRoot = forwardRef(
(
{
className,
value,
errorCorrection = 'M',
version,
size = 'md',
presentation = 'inline',
open,
onOpenChange,
children,
...props
},
ref
) => {
const slots = qrCodeVariants({ size });
/*
* A logo is a child, and the matrix has to know about it before the child
* renders — the level it forces changes the matrix itself. Rather than
* inspecting children, the fraction is a constant the Logo part also uses,
* and the root asks whether one is present at all.
*/
const logoFraction = hasLogo(children) ? LOGO_FRACTION : 0;
const level = levelFor(errorCorrection, logoFraction);
const { matrix, error } = useMemo(() => {
try {
return { matrix: encodeQr(value, { errorCorrection: level, version }), error: null };
} catch (cause) {
// Too much data, or a version too small for it. A throw here would
// take a screen down over a string, so it draws nothing and says why.
return { matrix: null, error: (cause as Error).message };
}
}, [value, level, version]);
const context: QRCodeContextValue = { matrix, error, value, size, logoFraction, slots };
if (presentation !== 'inline') {
// The provider stays outside the Popover, and Content re-provides it
// inside — portal content mounts under the portal host, which is not in
// this subtree.
return (
{children}
);
}
return (
{children}
);
}
);
QRCodeRoot.displayName = 'QRCode';
/* ------------------------------------------------------------------ *
* The code itself.
* ------------------------------------------------------------------ */
export interface QRCodeCanvasProps extends Omit {
className?: string;
/** Side length in points. Defaults to the size variant's. */
pixelSize?: number;
/** Dark modules. See the note below before overriding this. */
color?: string;
/** The plate the modules sit on. See the note below. */
backgroundColor?: string;
/**
* How a data module is drawn.
*
* `rounded` and `classy` join to their neighbours — a corner is rounded only
* where both cells touching it are light — so a run reads as one stroke
* rather than as a string of beads with a light seam through it.
*
* `dot` and `diamond` do not tile, and cost read distance for it: `dot`
* covers about two thirds of its cell and `diamond` exactly half, so the
* same code is a fainter code at the same size. Raise `errorCorrection` with
* them, and check a printed one rather than a screen.
*/
moduleShape?: QRCodeModuleShape;
/** How the ring around each of the three corner eyes is drawn. */
eyeFrameShape?: QRCodeEyeFrameShape;
/** How the square inside each corner eye is drawn. */
eyeBallShape?: QRCodeEyeBallShape;
/** The three corner rings. Defaults to `color`. */
eyeFrameColor?: string;
/** The three corner centres. Defaults to `eyeFrameColor`, then `color`. */
eyeBallColor?: string;
}
/**
* Dark modules on a light plate, whatever the theme is doing.
*
* This is the one place in the library that does not follow the tokens, and it
* is deliberate. A QR code is not a surface — it is a thing a camera has to
* read, and readers expect dark on light. Inverted, a code is rejected outright
* by a good share of scanners and found late by most of the rest, which turns a
* dark theme into a bug report about a code that "sometimes does not work".
*
* On a light theme the plate is white on a near-white card, which looks like
* nothing at all — correct, and the intended outcome. On a dark theme it reads
* as a light plate holding a code, which is the shape everyone recognises.
*
* `color` and `backgroundColor` override both, for a code that is definitely
* being read by something you control.
*/
const PLATE = '#ffffff';
const MODULE = '#111111';
const QRCodeCanvas = forwardRef(
(
{
className,
pixelSize,
color,
backgroundColor,
moduleShape = 'square',
eyeFrameShape = 'square',
eyeBallShape = 'square',
eyeFrameColor,
eyeBallColor,
...props
},
ref
) => {
const { matrix, error, value, size, logoFraction } = useQRCode('QRCode.Canvas');
const side = pixelSize ?? CANVAS_SIZE[size];
const dark = color ?? MODULE;
const light = backgroundColor ?? PLATE;
const frameInk = eyeFrameColor ?? dark;
const ballInk = eyeBallColor ?? frameInk;
/*
* Three paths for the whole code: the body, the three corner rings, and
* the three corner centres.
*
* Three rather than one because they are three different things a reader
* can colour and shape separately — and still three rather than one node
* per module, which is what a version 10 code would cost: 3,481 cells,
* half of them dark, seventeen hundred native views for a picture that
* never changes.
*
* The viewBox is in module units, so a shape is written once and scales to
* whatever `side` is — no arithmetic per module, and no rounding gaps
* between neighbours at fractional sizes.
*/
const paths = useMemo(() => {
if (!matrix) return { body: '', frames: '', balls: '' };
const hole = logoFraction ? holeBounds(matrix.size, logoFraction) : null;
/*
* The body, as its own matrix rather than as a filter inside the loop.
* The rounded shapes ask their neighbours whether a corner is free, and
* the answer has to be about what is *drawn* — a module beside the logo
* hole or beside an eye has nothing there to join to, and joining to it
* leaves a squared-off corner facing a gap.
*/
const body: boolean[][] = [];
for (let y = 0; y < matrix.size; y++) {
const row: boolean[] = [];
for (let x = 0; x < matrix.size; x++) {
const lit =
!!matrix.modules[y]![x] &&
!inFinder(x, y, matrix.size) &&
// Modules under the logo are not drawn at all — drawing them and
// covering them leaves a dark edge wherever the logo is smaller
// than the square it cleared.
!(hole && x >= hole.from && x < hole.to && y >= hole.from && y < hole.to);
row.push(lit);
}
body.push(row);
}
let d = '';
for (let y = 0; y < matrix.size; y++) {
for (let x = 0; x < matrix.size; x++) {
if (!body[y]![x]) continue;
d += modulePath(moduleShape, body, x, y, matrix.size, QUIET_ZONE);
}
}
let frames = '';
let balls = '';
for (const [x, y, corner] of finderOrigins(matrix.size)) {
frames += eyeFramePath(eyeFrameShape, x, y, QUIET_ZONE, corner);
balls += eyeBallPath(eyeBallShape, x, y, QUIET_ZONE);
}
return { body: d, frames, balls };
}, [matrix, logoFraction, moduleShape, eyeFrameShape, eyeBallShape]);
if (error) {
// A box the size the code would have been, so the layout does not jump
// when the value becomes something that fits.
return (
);
}
if (!matrix) return null;
const grid = matrix.size + QUIET_ZONE * 2;
return (
);
}
);
QRCodeCanvas.displayName = 'QRCode.Canvas';
/* ------------------------------------------------------------------ *
* The shells around it.
* ------------------------------------------------------------------ */
export interface QRCodeFrameProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The tray the code sits in — the same widget shell the charts use: a titled
* strip, and a card flush inside it holding the thing itself.
*/
const QRCodeFrame = forwardRef(
({ className, children, ...props }, ref) => {
const { slots } = useQRCode('QRCode.Frame');
return (
{children}
);
}
);
QRCodeFrame.displayName = 'QRCode.Frame';
export interface QRCodeHeaderProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The strip across the top of the tray. `QRCode.Title` takes the flexible
* side and `QRCode.Action` the end, so a long title truncates rather than
* shoving the trailing slot off the edge.
*/
const QRCodeHeader = forwardRef(
({ className, children, ...props }, ref) => {
const { slots } = useQRCode('QRCode.Header');
return (
{children}
);
}
);
QRCodeHeader.displayName = 'QRCode.Header';
export interface QRCodeTitleProps {
className?: string;
children?: ReactNode;
}
/** What the code is for. Muted — it is a caption on the tray, not a heading. */
function QRCodeTitle({ className, children }: QRCodeTitleProps) {
useQRCode('QRCode.Title');
return (
{children}
);
}
QRCodeTitle.displayName = 'QRCode.Title';
export interface QRCodeActionProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The trailing slot on the header row — an expiry, a count, a button. A plain
* string draws as muted text; anything else draws as itself.
*/
const QRCodeAction = forwardRef(
({ className, children, ...props }, ref) => {
useQRCode('QRCode.Action');
return (
{typeof children === 'string' ? (
{children}
) : (
children
)}
);
}
);
QRCodeAction.displayName = 'QRCode.Action';
export interface QRCodePanelProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/**
* The card the code is drawn on, flush inside the tray. It is also what the
* logo positions itself against, so a `QRCode.Logo` belongs in here beside the
* canvas rather than anywhere else.
*/
const QRCodePanel = forwardRef(
({ className, children, ...props }, ref) => {
const { slots } = useQRCode('QRCode.Panel');
return (
{children}
);
}
);
QRCodePanel.displayName = 'QRCode.Panel';
/* ------------------------------------------------------------------ *
* The readouts.
* ------------------------------------------------------------------ */
export interface QRCodeDescriptionProps {
className?: string;
children?: ReactNode;
}
/** A muted line inside the panel, under the code. */
function QRCodeDescription({ className, children }: QRCodeDescriptionProps) {
useQRCode('QRCode.Description');
return (
{children}
);
}
QRCodeDescription.displayName = 'QRCode.Description';
export interface QRCodeCaptionProps {
className?: string;
children?: ReactNode;
}
/** A line under the code saying what scanning it does. */
function QRCodeCaption({ className, children }: QRCodeCaptionProps) {
const { slots } = useQRCode('QRCode.Caption');
return {children};
}
QRCodeCaption.displayName = 'QRCode.Caption';
export interface QRCodeValueProps {
className?: string;
/** Show the whole string rather than one line of it. */
full?: boolean;
}
/**
* The encoded string itself, for someone who cannot scan it — typing a URL out
* is slower than pointing a camera at it and it is the only way through when
* the camera is the thing you are setting up.
*/
function QRCodeValue({ className, full }: QRCodeValueProps) {
const { value, slots } = useQRCode('QRCode.Value');
return (
{value}
);
}
QRCodeValue.displayName = 'QRCode.Value';
/* ------------------------------------------------------------------ *
* The logo.
* ------------------------------------------------------------------ */
/**
* How much of the code's width the logo hole takes.
*
* Constant rather than a prop, because the value has to be known before the
* matrix is built — it decides the error-correction level — and a prop on a
* child cannot be read from the parent without inspecting the tree. A quarter
* costs about 6% of the modules, which every level above `L` can afford.
*/
const LOGO_FRACTION = 0.25;
export interface QRCodeLogoProps extends ViewProps {
className?: string;
children?: ReactNode;
}
/** Whether the tree contains a `QRCode.Logo`, without walking it deeply. */
function hasLogo(children: ReactNode): boolean {
let found = false;
const walk = (node: ReactNode) => {
if (found || !node) return;
if (Array.isArray(node)) {
node.forEach(walk);
return;
}
const element = node as ReactElement<{ children?: ReactNode }> & { type?: unknown };
if (typeof element !== 'object' || !('type' in element)) return;
if (element.type === QRCodeLogo) {
found = true;
return;
}
walk(element.props?.children);
};
walk(children);
return found;
}
/**
* Content for the cleared square in the middle — a mark, an avatar, an icon.
* Its presence is what clears the square, and what raises the error-correction
* level if the one asked for could not afford it.
*/
const QRCodeLogo = forwardRef(
({ className, children, ...props }, ref) => {
const { size, matrix } = useQRCode('QRCode.Logo');
if (!matrix) return null;
/*
* Sized from the same square the canvas cleared, in the same units.
*
* Taking a flat 25% of the canvas instead is close, but not equal — the
* hole is a whole number of modules and the canvas is not necessarily
* divisible by them, so the two disagreed by a module or so and the
* difference showed as a dark ring around the logo.
*/
const { from, to } = holeBounds(matrix.size, LOGO_FRACTION);
const grid = matrix.size + QUIET_ZONE * 2;
const box = ((to - from) / grid) * CANVAS_SIZE[size];
return (
// `inset-0` and centred, not `absolute` with nothing else: an absolute
// box with no offsets takes its parent's alignment, which put the logo
// at the top of the panel rather than over the middle of the code.
{children}
);
}
);
QRCodeLogo.displayName = 'QRCode.Logo';
/* ------------------------------------------------------------------ *
* Folded away.
* ------------------------------------------------------------------ */
export interface QRCodeTriggerProps {
/** The element that opens it. Must accept `onPress`. */
children: ReactElement<{ onPress?: (...args: unknown[]) => void }>;
}
/** What opens a `popover` or `bottom-sheet` code. */
function QRCodeTrigger({ children }: QRCodeTriggerProps) {
useQRCode('QRCode.Trigger');
return {children};
}
QRCodeTrigger.displayName = 'QRCode.Trigger';
export interface QRCodeContentProps extends PopoverContentProps {}
/** The panel the code is drawn in, when it is not drawn in place. */
function QRCodeContent({ className, width = 'content-fit', children, ...props }: QRCodeContentProps) {
const context = useQRCode('QRCode.Content');
return (
{children}
);
}
QRCodeContent.displayName = 'QRCode.Content';
export const QRCode = Object.assign(QRCodeRoot, {
Canvas: QRCodeCanvas,
Frame: QRCodeFrame,
Header: QRCodeHeader,
Title: QRCodeTitle,
Action: QRCodeAction,
Panel: QRCodePanel,
Description: QRCodeDescription,
Caption: QRCodeCaption,
Value: QRCodeValue,
Logo: QRCodeLogo,
Trigger: QRCodeTrigger,
Content: QRCodeContent,
});