UI Patterns
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

components/examples/barcode-input.tsx
"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 } | null

Notes:

  • 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-8471 for ISSN vs 96385074 for GTIN-8).
  • Type is auto-detected. The input doesn't ask the user to pick a format. Length and prefix (978/979 for 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 useEffect cleanup 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 in X can switch to the full keyboard.

Further reading

On this page