Barcode Scanner

Camera-based barcode scanning with an animated reticle overlay and a bottom-sheet result card. Used in Scanflo for building physical media catalogs.

Overview

Two cooperating views form the scanning experience:

  • ScanReticleOverlay — full-screen layer on top of the camera preview. Dims the surroundings with a rectangular cutout window, draws corner brackets, and animates a sweep line while scanning.
  • CaptureCardView — bottom-sheet shown on detect. Runs a background metadata lookup and cycles through loading → resolved → saved or failed, letting the user edit before saving to SwiftData.

Scan Reticle

Point at barcode

States

Scanning — white corner brackets + animated sweep line
Detected — brackets turn green (0.25 s ease-in-out), sweep line removed

Implementation notes

  • ScanCutoutShape uses even-odd fill so SwiftUI never draws inside the window
  • Canvas draws all four corner brackets in a single pass
  • Sweep line uses .repeatForever(autoreverses: true) — set isAnimating = false on detect to stop it

Capture Card

📚
The Design of Everyday Things
978-0-465-06710-7
Title
The Design of Everyday Things
+ Add to Collection

Lookup states

.loading
ProgressView spinner; title field disabled; shown immediately on detect
.resolved
Title populated from API; user can edit; media-type icon badge shown
.failed
Error icon; manual title entry required; still saveable
  • Uses .regularMaterial to inherit camera feed colour
  • Lookup runs in .task — auto-cancelled if the sheet is dismissed
  • Save button disabled while title.isEmpty to avoid orphaned records

Code

SwiftUI tabs show ScanReticleOverlay (iOS) and CaptureCardView (macOS tab). React and HTML tabs show the equivalent web implementation using the BarcodeDetector API.

BarcodeScanner.tsx
tsx
"use client";

import { useEffect, useRef, useState } from "react";

// Web barcode scanner using the BarcodeDetector API (Chrome/Edge 83+)
// or a library like @zxing/library for broader browser support.

type LookupPhase = "idle" | "scanning" | "resolved" | "failed";

export function BarcodeScanner() {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [phase, setPhase] = useState<LookupPhase>("idle");
  const [result, setResult] = useState("");
  const [title, setTitle] = useState("");

  useEffect(() => {
    if (!("BarcodeDetector" in window)) return;
    let stream: MediaStream;
    let raf: number;

    navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }).then((s) => {
      stream = s;
      if (videoRef.current) videoRef.current.srcObject = s;
      setPhase("scanning");

      const detector = new (window as any).BarcodeDetector({ formats: ["ean_13", "qr_code", "upc_a"] });

      const scan = async () => {
        if (videoRef.current?.readyState === 4) {
          const codes = await detector.detect(videoRef.current);
          if (codes.length > 0) {
            setResult(codes[0].rawValue);
            setPhase("resolved");
            lookup(codes[0].rawValue);
            return; // stop scanning
          }
        }
        raf = requestAnimationFrame(scan);
      };
      raf = requestAnimationFrame(scan);
    });

    return () => { stream?.getTracks().forEach((t) => t.stop()); cancelAnimationFrame(raf); };
  }, []);

  const lookup = async (barcode: string) => {
    try {
      const res = await fetch(`https://openlibrary.org/api/books?bibkeys=ISBN:${barcode}&format=json&jscmd=data`);
      const data = await res.json();
      const book = data[`ISBN:${barcode}`];
      setTitle(book?.title ?? "");
    } catch {
      setPhase("failed");
    }
  };

  return (
    <div style={{ maxWidth: 340, margin: "0 auto" }}>
      {/* Camera viewport */}
      <div style={{ position: "relative", aspectRatio: "4/3", borderRadius: 16, overflow: "hidden", background: "#000" }}>
        <video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
        {/* Reticle overlay */}
        <div style={{
          position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
          background: "rgba(0,0,0,0.45)",
          WebkitMaskImage: "radial-gradient(150px 150px at 50% 50%, transparent 99%, black 100%)",
          maskImage: "radial-gradient(150px 150px at 50% 50%, transparent 99%, black 100%)",
        }} />
        <p style={{ position: "absolute", bottom: 12, left: 0, right: 0, textAlign: "center", color: "rgba(255,255,255,0.7)", fontSize: 13 }}>
          {phase === "scanning" ? "Point at barcode" : phase === "resolved" ? "Found!" : "Starting camera…"}
        </p>
      </div>

      {/* Result card */}
      {phase === "resolved" && (
        <div style={{ marginTop: 12, padding: "16px", borderRadius: 12, background: "var(--surface)", border: "1px solid var(--border)" }}>
          <p style={{ fontSize: 12, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>Title</p>
          <input value={title} onChange={(e) => setTitle(e.target.value)} style={{ width: "100%", padding: "8px 12px", borderRadius: 8, border: "1px solid var(--border)", background: "transparent", color: "var(--text-primary)", fontSize: 14 }} />
          <button style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 8, background: "var(--accent)", color: "#fff", fontWeight: 600, border: "none", cursor: "pointer" }}>
            Save to Collection
          </button>
        </div>
      )}
    </div>
  );
}

Accessibility

ConcernApproach
Camera permissionRequest only on first scan; include NSCameraUsageDescription reason string
VoiceOverReticle overlay sets .accessibilityHidden(true); card announces state changes via .accessibilityValue
Reduce MotionSweep-line animation checks @Environment(\.accessibilityReduceMotion)
Dynamic TypeAll card text uses system font styles — scales automatically