Input
Barcode Input
Barcode input with checksum validation and camera-based scanning via zbar-wasm.
Examples
Barcode input with validation and scanner
An input for numeric barcodes (GTIN/EAN/UPC, ISBN, ISSN) that validates the check digit on every keystroke and provides an in-field button to scan a physical barcode with the device camera.
Valid ISBN-13
"use client";
import * as React from "react";
import { ScanBarcode, X, LoaderCircle } from "lucide-react";
import { Dialog } from "@base-ui/react/dialog";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
// ---------------------------------------------------------------------------
// Barcode type detection & checksum validation
// ---------------------------------------------------------------------------
type BarcodeType =
| "GTIN-8"
| "GTIN-12"
| "GTIN-13"
| "GTIN-14"
| "ISBN-10"
| "ISBN-13"
| "ISSN"
| null;
/** Strip hyphens and spaces — common in ISBN/ISSN formatting. */
function normalize(raw: string): string {
return raw.replace(/[-\s]/g, "");
}
/**
* Mod-10 check digit used by GTIN-8, GTIN-12 (UPC-A), GTIN-13 (EAN-13),
* GTIN-14, and ISBN-13. Digits are weighted alternately 1 and 3 from the
* right (i.e. 3 and 1 from the left for odd-length codes).
*/
function isValidGTIN(digits: string): boolean {
const len = digits.length;
if (![8, 12, 13, 14].includes(len)) return false;
if (!/^\d+$/.test(digits)) return false;
let sum = 0;
for (let i = 0; i < len; i++) {
const weight = (len - 1 - i) % 2 === 0 ? 1 : 3;
sum += Number(digits[i]) * weight;
}
return sum % 10 === 0;
}
/**
* ISBN-10: 10 characters, last may be 'X' (value 10).
* Weighted 10, 9, 8 … 1 — sum must be divisible by 11.
*/
function isValidISBN10(code: string): boolean {
if (code.length !== 10) return false;
if (!/^\d{9}[\dX]$/i.test(code)) return false;
let sum = 0;
for (let i = 0; i < 9; i++) {
sum += Number(code[i]) * (10 - i);
}
sum += code[9].toUpperCase() === "X" ? 10 : Number(code[9]);
return sum % 11 === 0;
}
/**
* ISSN: 8 characters (often formatted XXXX-XXXX), last may be 'X'.
* Weighted 8, 7, 6 … 1 — sum must be divisible by 11.
*/
function isValidISSN(code: string): boolean {
if (code.length !== 8) return false;
if (!/^\d{7}[\dX]$/i.test(code)) return false;
let sum = 0;
for (let i = 0; i < 7; i++) {
sum += Number(code[i]) * (8 - i);
}
sum += code[7].toUpperCase() === "X" ? 10 : Number(code[7]);
return sum % 11 === 0;
}
function detectType(raw: string): BarcodeType {
const code = normalize(raw);
if (code.length === 0) return null;
// ISSN: 8 chars, may end in X, formatted with a hyphen
if (code.length === 8 && /^\d{7}[\dX]$/i.test(code) && raw.includes("-")) {
return "ISSN";
}
// ISBN-10: 10 chars, may end in X
if (code.length === 10 && /^\d{9}[\dX]$/i.test(code)) return "ISBN-10";
// ISBN-13: starts with 978 or 979
if (code.length === 13 && /^(978|979)/.test(code)) return "ISBN-13";
// GTIN family
if (/^\d+$/.test(code)) {
if (code.length === 8) return "GTIN-8";
if (code.length === 12) return "GTIN-12";
if (code.length === 13) return "GTIN-13";
if (code.length === 14) return "GTIN-14";
}
return null;
}
function validate(raw: string): { type: BarcodeType; valid: boolean } | null {
const code = normalize(raw);
if (code.length === 0) return null;
const type = detectType(raw);
if (!type) return null;
switch (type) {
case "ISBN-10":
return { type, valid: isValidISBN10(code) };
case "ISSN":
return { type, valid: isValidISSN(code) };
default:
// GTIN-8, GTIN-12, GTIN-13, GTIN-14, ISBN-13
return { type, valid: isValidGTIN(code) };
}
}
// ---------------------------------------------------------------------------
// Barcode scanner dialog (camera + zbar-wasm)
// ---------------------------------------------------------------------------
function ScannerDialog({
open,
onOpenChange,
onScan,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onScan: (value: string) => void;
}) {
const videoRef = React.useRef<HTMLVideoElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const rafRef = React.useRef<number>(0);
const [error, setError] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
if (!open) return;
let cancelled = false;
async function start() {
try {
const { scanImageData } = await import("@undecaf/zbar-wasm");
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: "environment" },
});
if (cancelled) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
const video = videoRef.current!;
video.srcObject = stream;
await video.play();
if (cancelled) return;
setLoading(false);
const canvas = canvasRef.current!;
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
const tick = async () => {
if (cancelled) return;
if (video.readyState >= video.HAVE_CURRENT_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const imageData = ctx.getImageData(
0,
0,
canvas.width,
canvas.height,
);
try {
const symbols = await scanImageData(imageData);
if (symbols.length > 0 && !cancelled) {
const decoded = symbols[0].decode();
onScan(decoded);
onOpenChange(false);
return;
}
} catch {
// scan failed for this frame — continue
}
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
} catch (err) {
if (!cancelled) {
let message = "Could not start the camera.";
if (!navigator.mediaDevices?.getUserMedia) {
message =
"Camera API is not available. Make sure the page is served over HTTPS.";
} else if (
err instanceof DOMException &&
err.name === "NotAllowedError"
) {
message =
"Camera access was denied. Allow camera permissions and try again.";
} else if (
err instanceof DOMException &&
err.name === "NotFoundError"
) {
message = "No camera found on this device.";
}
setError(message);
setLoading(false);
}
}
}
start();
return () => {
cancelled = true;
cancelAnimationFrame(rafRef.current);
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
setError(null);
setLoading(true);
};
}, [open, onScan, onOpenChange]);
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Backdrop
className={cn(
"fixed inset-0 z-50 bg-black/50 transition-opacity",
"data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
)}
/>
<Dialog.Popup
className={cn(
"bg-background fixed top-1/2 left-1/2 z-50 grid w-full max-w-md -translate-x-1/2 -translate-y-1/2 gap-4 rounded-lg border p-6 shadow-lg transition-all",
"data-[ending-style]:scale-95 data-[ending-style]:opacity-0",
"data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
)}
>
<div className="flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold leading-none">
Scan barcode
</Dialog.Title>
<Dialog.Close
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 focus-visible:border-ring rounded-md p-1 outline-none focus-visible:ring-[3px]"
aria-label="Close"
>
<X className="size-4" aria-hidden="true" />
</Dialog.Close>
</div>
<Dialog.Description className="sr-only">
Point your camera at a barcode to scan it.
</Dialog.Description>
<div className="bg-muted relative aspect-video overflow-hidden rounded-md">
{loading && !error && (
<div className="absolute inset-0 flex items-center justify-center">
<LoaderCircle
className="text-muted-foreground size-6 animate-spin"
aria-hidden="true"
/>
<span className="sr-only">Starting camera…</span>
</div>
)}
{error && (
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center p-4 text-center text-sm">
{error}
</div>
)}
<video
ref={videoRef}
className={cn(
"h-full w-full object-cover",
(loading || error) && "invisible",
)}
muted
playsInline
/>
<canvas ref={canvasRef} className="hidden" />
</div>
<p className="text-muted-foreground text-center text-xs">
Point your camera at a barcode. It will be detected automatically.
</p>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
);
}
// ---------------------------------------------------------------------------
// Demo component
// ---------------------------------------------------------------------------
export function BarcodeInputDemo() {
const id = React.useId();
const feedbackId = `${id}-feedback`;
const [value, setValue] = React.useState("978-0-13-468599-1");
const [scannerOpen, setScannerOpen] = React.useState(false);
const result = React.useMemo(() => validate(value), [value]);
const handleScan = React.useCallback((decoded: string) => {
setValue(decoded);
}, []);
return (
<div className="grid w-full gap-2">
<Label htmlFor={id}>Barcode</Label>
<div className="relative">
<Input
id={id}
type="text"
inputMode="numeric"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter a barcode (EAN, UPC, ISBN, ISSN)"
aria-describedby={result ? feedbackId : undefined}
aria-invalid={result ? !result.valid : undefined}
className="pr-10"
/>
<button
type="button"
onClick={() => setScannerOpen(true)}
aria-label="Scan barcode with camera"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 focus-visible:border-ring absolute inset-y-0 right-0 flex items-center justify-center rounded-md px-3 outline-none focus-visible:ring-[3px]"
>
<ScanBarcode className="size-4" aria-hidden="true" />
</button>
</div>
<p
id={feedbackId}
aria-live="polite"
className={cn(
"min-h-4 text-xs",
result?.valid
? "text-emerald-600 dark:text-emerald-400"
: "text-muted-foreground",
)}
>
{result
? result.valid
? `Valid ${result.type}`
: `Invalid checksum for ${result.type}`
: null}
</p>
<ScannerDialog
open={scannerOpen}
onOpenChange={setScannerOpen}
onScan={handleScan}
/>
</div>
);
}Usage:
const [value, setValue] = useState("");
const result = validate(value);
// result: { type: "ISBN-13", valid: true } | nullNotes:
- Supported formats. GTIN-8, GTIN-12 (UPC-A), GTIN-13 (EAN-13), GTIN-14, ISBN-10, ISBN-13, and ISSN. All share one of two checksum algorithms — mod-10 (GTIN family) or mod-11 (ISBN-10, ISSN).
- Hyphens and spaces are ignored. ISBNs and ISSNs are commonly
written with hyphens (
978-0-13-468599-1,0317-8471). The validator strips them before checking, so users can paste formatted codes directly. - ISSN vs GTIN-8 ambiguity. Both are 8 digits. The component uses
the presence of a hyphen in the raw input to distinguish them — this
matches how each is conventionally formatted (
0317-8471for ISSN vs96385074for GTIN-8). - Type is auto-detected. The input doesn't ask the user to pick a
format. Length and prefix (
978/979for ISBN-13) are enough to classify unambiguously. - Checksum feedback is a hint, not a gate. Like the email typo suggestion, the validation message is informational. It does not prevent submission — the server should validate too.
- Scanner uses
@undecaf/zbar-wasm. The library is dynamically imported only when the user opens the scanner, so it adds zero weight to the initial bundle. It decodes EAN/UPC, ISBN, Code 128, QR codes and more from a camera feed. - Camera cleanup. The
useEffectcleanup stops the media stream and cancels the animation frame loop when the dialog closes, so the camera indicator turns off immediately. facingMode: "environment"requests the rear camera on mobile devices — the one pointed away from the user, which is what you want for scanning a barcode on a physical product.inputMode="numeric"brings up the numeric keyboard on mobile, appropriate for digit-only codes. Users who need to type an ISBN-10 ending inXcan switch to the full keyboard.
Further reading
- GS1 — Check Digit Calculator — the authoritative reference for GTIN check digits.
- Wikipedia — International Standard Book Number — ISBN-10 and ISBN-13 structure and check digit algorithms.
- Wikipedia — International Standard Serial Number — ISSN structure and mod-11 check digit.
@undecaf/zbar-wasm— WebAssembly port of the ZBar barcode reader.