bigbullui logobigbullui
Componentticket-stub

QR Reader

Turnstile simulated camera QR and barcode scanner viewfinder with animated laser sweep and targeting reticle.

Preview

Live Preview
TURNSTILE GATE 04B
SECTOR A // ENTRANCE
CAM: REAR 1XSTATUS: SCANNING RETICLEISO 400 // 60FPS
LIVE SENSORAUTO-FOCUS ON
ALIGN PASSZOOM: 1.0X
TAP TO SIMULATE ADMISSION
AWAITING TICKET STUB // TURNSTILE READY

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add qr-reader

Or install the full npm package:

npm install bigbullui

Usage

import { QrReader } from "@/components/ui/qr-reader";

<QrReader onScan={(data) => console.log(data.code, data.attendee)} />

Source code

qr-reader.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface TicketScanData {
  code: string;
  type: "QR-MATRIX" | "BARCODE-128";
  attendee: string;
  tier: string;
  seat: string;
  gate: string;
  timestamp: string;
  status: "VALID" | "ALREADY-SCANNED" | "INVALID";
}

export interface QrReaderProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Header title for the turnstile scanner */
  title?: string;
  /** Gate identifier */
  gateId?: string;
  /** Whether the scanner lens is active */
  active?: boolean;
  /** Initial or controlled scan data */
  defaultScanResult?: TicketScanData | null;
  /** Callback fired when a valid ticket is detected */
  onScan?: (data: TicketScanData) => void;
  /** Callback fired when scanner is reset */
  onReset?: () => void;
  /** Whether to display the animated red laser sweep line */
  showLaser?: boolean;
  /** Whether to display corner bracket crosshairs */
  showCrosshairs?: boolean;
  /** Whether to display scanner controls */
  showControls?: boolean;
  /** Additional container classes */
  className?: string;
}

const DEFAULT_MOCK_TICKETS: TicketScanData[] = [
  {
    code: "TK-9428-VIP",
    type: "QR-MATRIX",
    attendee: "ALEXIS VANCE",
    tier: "VIP ORCHESTRA",
    seat: "SEC A · ROW 01 · SEAT 14",
    gate: "GATE 04B",
    timestamp: "20:14:02",
    status: "VALID",
  },
  {
    code: "TK-3310-GA",
    type: "BARCODE-128",
    attendee: "JORDAN REED",
    tier: "GENERAL ADMIT",
    seat: "SEC C · ROW 12 · SEAT 08",
    gate: "GATE 04B",
    timestamp: "20:14:18",
    status: "VALID",
  },
  {
    code: "TK-8854-PIT",
    type: "QR-MATRIX",
    attendee: "SAMIRA KHAN",
    tier: "FRONT PIT PASS",
    seat: "PIT · ROW AA · SEAT 03",
    gate: "GATE 04B",
    timestamp: "20:14:35",
    status: "VALID",
  },
];

export function QrReader({
  title = "TURNSTILE SCANNER",
  gateId = "GATE 04B // TURNSTILE-02",
  active = true,
  defaultScanResult = null,
  onScan,
  onReset,
  showLaser = true,
  showCrosshairs = true,
  showControls = true,
  className,
  ...props
}: QrReaderProps) {
  const [isScanning, setIsScanning] = React.useState(active);
  const [laserActive, setLaserActive] = React.useState(showLaser);
  const [torchOn, setTorchOn] = React.useState(false);
  const [scanResult, setScanResult] = React.useState<TicketScanData | null>(
    defaultScanResult
  );
  const [detectionPulse, setDetectionPulse] = React.useState(false);
  const [cameraMode, setCameraMode] = React.useState<"REAR 1X" | "REAR 0.5X" | "FRONT">(
    "REAR 1X"
  );
  const [mockIndex, setMockIndex] = React.useState(0);

  // Sync prop changes
  React.useEffect(() => {
    setIsScanning(active);
  }, [active]);

  React.useEffect(() => {
    setLaserActive(showLaser);
  }, [showLaser]);

  const triggerScanSimulation = React.useCallback(() => {
    if (!isScanning && scanResult) {
      // If already scanned, reset first
      setScanResult(null);
      setIsScanning(true);
      setDetectionPulse(false);
      onReset?.();
      return;
    }

    const nextTicket = DEFAULT_MOCK_TICKETS[mockIndex % DEFAULT_MOCK_TICKETS.length];
    setMockIndex((prev) => prev + 1);

    // Trigger visual pulse
    setDetectionPulse(true);
    setScanResult(nextTicket);
    setIsScanning(false);
    onScan?.(nextTicket);

    const timer = setTimeout(() => {
      setDetectionPulse(false);
    }, 1800);

    return () => clearTimeout(timer);
  }, [isScanning, scanResult, mockIndex, onScan, onReset]);

  const handleReset = React.useCallback(() => {
    setScanResult(null);
    setIsScanning(true);
    setDetectionPulse(false);
    onReset?.();
  }, [onReset]);

  const cycleCamera = React.useCallback(() => {
    setCameraMode((prev) => {
      if (prev === "REAR 1X") return "REAR 0.5X";
      if (prev === "REAR 0.5X") return "FRONT";
      return "REAR 1X";
    });
  }, []);

  return (
    <div
      className={cn(
        "relative flex flex-col w-full max-w-md overflow-hidden rounded-xl border-2 border-foreground bg-card shadow-md select-none font-mono text-card-foreground",
        className
      )}
      role="region"
      aria-label={title}
      {...props}
    >
      <style>{`
        @keyframes laserSweep {
          0% { top: 4%; opacity: 0.9; }
          50% { top: 92%; opacity: 1; }
          100% { top: 4%; opacity: 0.9; }
        }
        @keyframes pulseRingSweep {
          0% { transform: scale(0.96); opacity: 0.9; }
          50% { transform: scale(1.06); opacity: 0.3; }
          100% { transform: scale(0.96); opacity: 0.9; }
        }
      `}</style>

      {/* Ticket Edge Notches */}
      <div
        className="size-5 rounded-full bg-background border-2 border-foreground absolute top-1/2 -left-2.5 -translate-y-1/2 z-30 pointer-events-none"
        aria-hidden="true"
      />
      <div
        className="size-5 rounded-full bg-background border-2 border-foreground absolute top-1/2 -right-2.5 -translate-y-1/2 z-30 pointer-events-none"
        aria-hidden="true"
      />

      {/* Scanner Headstock */}
      <div className="flex items-center justify-between border-b-2 border-foreground bg-secondary px-4 py-2.5">
        <div className="flex items-center gap-2">
          <span
            className={cn(
              "size-2.5 rounded-full transition-colors",
              scanResult
                ? "bg-emerald-500 shadow-[0_0_8px_#10b981]"
                : isScanning
                ? "bg-destructive animate-pulse shadow-[0_0_8px_#ef4444]"
                : "bg-muted-foreground"
            )}
          />
          <span className="font-mono text-xs font-black uppercase tracking-wider text-foreground">
            {title}
          </span>
        </div>
        <span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
          {gateId}
        </span>
      </div>

      {/* Subheader Status Bar */}
      <div className="flex items-center justify-between border-b border-dashed border-border px-4 py-1.5 text-[10px] text-muted-foreground uppercase">
        <span>CAM: {cameraMode}</span>
        <span>
          STATUS:{" "}
          <strong
            className={cn(
              "font-bold",
              scanResult ? "text-emerald-600 dark:text-emerald-400" : "text-foreground"
            )}
          >
            {scanResult ? "PASS VALIDATED" : isScanning ? "SCANNING RETICLE" : "STANDBY"}
          </strong>
        </span>
        <span>ISO 400 // 60FPS</span>
      </div>

      {/* Viewfinder Main Viewport */}
      <div className="relative p-4 sm:p-5">
        <div
          onClick={triggerScanSimulation}
          className={cn(
            "relative w-full aspect-square max-h-[300px] sm:max-h-[320px] mx-auto rounded-lg overflow-hidden border-2 border-foreground bg-neutral-950 text-neutral-100 flex items-center justify-center cursor-pointer group shadow-inner transition-all",
            torchOn && "ring-4 ring-amber-300/40",
            detectionPulse && "ring-4 ring-emerald-500 shadow-[0_0_24px_rgba(16,185,129,0.6)]"
          )}
          title="Click to simulate ticket barcode scan"
        >
          {/* Torch Light Ambient Simulation */}
          {torchOn && (
            <div className="absolute inset-0 bg-amber-100/15 pointer-events-none z-10 mix-blend-screen" />
          )}

          {/* Camera Sensor Grid Overlay */}
          <div
            className="absolute inset-0 opacity-10 pointer-events-none"
            style={{
              backgroundImage:
                "linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)",
              backgroundSize: "24px 24px",
            }}
          />

          {/* Viewfinder HUD Micro Metadata */}
          <div className="absolute top-2.5 left-3 right-3 flex items-center justify-between text-[9px] font-mono tracking-widest text-neutral-400 pointer-events-none z-20">
            <span className="flex items-center gap-1.5">
              <span className="size-1.5 rounded-full bg-red-500 animate-ping" />
              <span>LIVE SENSOR</span>
            </span>
            <span>AUTO-FOCUS ON</span>
          </div>

          <div className="absolute bottom-2.5 left-3 right-3 flex items-center justify-between text-[9px] font-mono tracking-widest text-neutral-400 pointer-events-none z-20">
            <span>ALIGN PASS</span>
            <span>ZOOM: 1.0X</span>
          </div>

          {/* Center Targeting Reticle Container */}
          <div className="relative size-48 sm:size-52 flex items-center justify-center">
            {/* Target Reticle Frame */}
            <div
              className={cn(
                "relative size-full rounded-md border-2 border-dashed transition-colors duration-300 flex items-center justify-center",
                scanResult
                  ? "border-emerald-500 bg-emerald-950/20"
                  : "border-accent/70 bg-black/40"
              )}
            >
              {/* Corner Bracket Crosshairs */}
              {showCrosshairs && (
                <>
                  {/* Top-Left */}
                  <div className="absolute -top-1 -left-1 size-4 border-t-2 border-l-2 border-accent" />
                  {/* Top-Right */}
                  <div className="absolute -top-1 -right-1 size-4 border-t-2 border-r-2 border-accent" />
                  {/* Bottom-Left */}
                  <div className="absolute -bottom-1 -left-1 size-4 border-b-2 border-l-2 border-accent" />
                  {/* Bottom-Right */}
                  <div className="absolute -bottom-1 -right-1 size-4 border-b-2 border-r-2 border-accent" />
                </>
              )}

              {/* Center Crosshair Tick */}
              <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
                <div className="relative size-4 opacity-50">
                  <div className="absolute left-1/2 top-0 h-full w-[1.5px] -translate-x-1/2 bg-accent" />
                  <div className="absolute top-1/2 left-0 w-full h-[1.5px] -translate-y-1/2 bg-accent" />
                </div>
              </div>

              {/* Simulated QR Code Silhouette Pattern */}
              <div
                className={cn(
                  "relative size-32 p-1.5 transition-opacity duration-300",
                  scanResult ? "opacity-100" : "opacity-35 group-hover:opacity-50"
                )}
              >
                <svg
                  viewBox="0 0 100 100"
                  className={cn(
                    "size-full transition-colors",
                    scanResult ? "text-emerald-400" : "text-neutral-200"
                  )}
                  fill="currentColor"
                  aria-hidden="true"
                >
                  {/* QR Finder Pattern Top-Left */}
                  <rect x="0" y="0" width="28" height="28" fill="currentColor" rx="2" />
                  <rect x="4" y="4" width="20" height="20" fill="#0a0a0a" rx="1" />
                  <rect x="8" y="8" width="12" height="12" fill="currentColor" rx="1" />

                  {/* QR Finder Pattern Top-Right */}
                  <rect x="72" y="0" width="28" height="28" fill="currentColor" rx="2" />
                  <rect x="76" y="4" width="20" height="20" fill="#0a0a0a" rx="1" />
                  <rect x="80" y="8" width="12" height="12" fill="currentColor" rx="1" />

                  {/* QR Finder Pattern Bottom-Left */}
                  <rect x="0" y="72" width="28" height="28" fill="currentColor" rx="2" />
                  <rect x="4" y="76" width="20" height="20" fill="#0a0a0a" rx="1" />
                  <rect x="8" y="80" width="12" height="12" fill="currentColor" rx="1" />

                  {/* QR Matrix Payload Bits */}
                  <rect x="34" y="6" width="8" height="8" />
                  <rect x="48" y="10" width="6" height="6" />
                  <rect x="60" y="4" width="6" height="8" />
                  <rect x="36" y="22" width="6" height="6" />
                  <rect x="50" y="24" width="8" height="6" />
                  <rect x="6" y="36" width="8" height="6" />
                  <rect x="20" y="34" width="6" height="8" />
                  <rect x="34" y="38" width="10" height="6" />
                  <rect x="52" y="36" width="8" height="8" />
                  <rect x="66" y="34" width="6" height="6" />
                  <rect x="78" y="38" width="8" height="8" />
                  <rect x="90" y="34" width="6" height="6" />
                  <rect x="10" y="50" width="6" height="8" />
                  <rect x="24" y="48" width="8" height="6" />
                  <rect x="38" y="52" width="6" height="8" />
                  <rect x="50" y="50" width="10" height="6" />
                  <rect x="68" y="48" width="6" height="8" />
                  <rect x="82" y="52" width="8" height="6" />
                  <rect x="36" y="68" width="8" height="8" />
                  <rect x="52" y="66" width="6" height="6" />
                  <rect x="66" y="70" width="8" height="6" />
                  <rect x="80" y="66" width="8" height="8" />
                  <rect x="38" y="82" width="6" height="8" />
                  <rect x="50" y="80" width="8" height="6" />
                  <rect x="64" y="84" width="6" height="8" />
                  <rect x="78" y="80" width="6" height="6" />
                  <rect x="90" y="82" width="6" height="8" />
                </svg>
              </div>

              {/* Animated Red Laser Sweep Line */}
              {laserActive && isScanning && !scanResult && (
                <div
                  className="absolute left-0 right-0 z-30 pointer-events-none"
                  style={{
                    animation: "laserSweep 2.2s ease-in-out infinite",
                  }}
                >
                  {/* Intense laser beam line */}
                  <div className="h-[2px] w-full bg-red-500 shadow-[0_0_10px_2px_#ef4444]" />
                  {/* Subtle red laser glow cone */}
                  <div className="h-6 w-full bg-gradient-to-b from-red-500/25 to-transparent pointer-events-none" />
                </div>
              )}

              {/* Success Detection Pulse Ring & Stamp */}
              {scanResult && (
                <div className="absolute inset-0 flex flex-col items-center justify-center z-40 bg-black/60 backdrop-blur-xs p-2 text-center">
                  <div className="animate-in fade-in zoom-in-95 duration-200 flex flex-col items-center">
                    <div className="size-10 rounded-full bg-emerald-500/20 border-2 border-emerald-500 flex items-center justify-center text-emerald-400 mb-1.5 shadow-[0_0_12px_#10b981]">
                      <svg
                        className="size-5"
                        fill="none"
                        viewBox="0 0 24 24"
                        stroke="currentColor"
                        strokeWidth="3"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                        aria-hidden="true"
                      >
                        <polyline points="20 6 9 17 4 12" />
                      </svg>
                    </div>
                    <span className="rotate-[-3deg] rounded-xs border border-emerald-400 bg-emerald-500 px-2 py-0.5 text-[11px] font-black tracking-widest text-neutral-950 uppercase shadow-xs">
                      ACCESS GRANTED
                    </span>
                    <span className="mt-1 font-mono text-[10px] font-bold text-emerald-400 tracking-wider">
                      {scanResult.code}
                    </span>
                  </div>
                </div>
              )}
            </div>
          </div>

          {/* Click hint badge */}
          <div className="absolute bottom-2 inset-x-0 flex justify-center pointer-events-none">
            <span className="rounded-xs bg-black/70 border border-neutral-700 px-2 py-0.5 font-mono text-[9px] text-neutral-300 uppercase tracking-wider backdrop-blur-xs">
              {scanResult ? "CLICK TO SCAN NEXT TICKET" : "TAP TO SIMULATE ADMISSION"}
            </span>
          </div>
        </div>
      </div>

      {/* Ticket Detection Stub Readout Card */}
      {scanResult ? (
        <div className="mx-4 mb-4 rounded-lg border-2 border-foreground bg-secondary/50 p-3 shadow-xs">
          <div className="flex items-center justify-between border-b border-dashed border-border pb-2">
            <div>
              <span className="font-mono text-[9px] uppercase tracking-widest text-muted-foreground block">
                VALIDATED PASS
              </span>
              <span className="font-mono text-sm font-bold text-foreground">
                {scanResult.attendee}
              </span>
            </div>
            <span className="rounded-xs border border-dashed border-emerald-600 bg-emerald-100 dark:bg-emerald-950/60 px-2 py-0.5 text-[10px] font-bold text-emerald-800 dark:text-emerald-300">
              {scanResult.tier}
            </span>
          </div>

          <div className="mt-2 grid grid-cols-2 gap-2 text-[10px]">
            <div>
              <span className="text-muted-foreground block">SEAT INFO:</span>
              <span className="font-bold text-foreground">{scanResult.seat}</span>
            </div>
            <div className="text-right">
              <span className="text-muted-foreground block">TIME &amp; GATE:</span>
              <span className="font-bold text-foreground">
                {scanResult.timestamp} · {scanResult.gate}
              </span>
            </div>
          </div>
        </div>
      ) : (
        <div className="mx-4 mb-4 rounded-lg border border-dashed border-border bg-muted/30 p-2.5 text-center">
          <span className="font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
            AWAITING TICKET STUB // TURNSTILE READY
          </span>
        </div>
      )}

      {/* Interactive Controls Bar */}
      {showControls && (
        <div className="flex flex-wrap items-center justify-between gap-2 border-t-2 border-foreground bg-card p-3">
          <div className="flex items-center gap-1.5">
            {/* Simulate Scan Button */}
            <button
              type="button"
              onClick={triggerScanSimulation}
              className="inline-flex items-center gap-1.5 rounded-sm border-2 border-foreground bg-accent px-2.5 py-1 text-xs font-bold text-accent-foreground shadow-xs transition-transform hover:scale-102 active:scale-98 cursor-pointer"
            >
              <svg
                className="size-3.5"
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth="2.5"
                strokeLinecap="round"
                strokeLinejoin="round"
                aria-hidden="true"
              >
                <path d="M3 7V5a2 2 0 0 1 2-2h2" />
                <path d="M17 3h2a2 2 0 0 1 2 2v2" />
                <path d="M21 17v2a2 2 0 0 1-2 2h-2" />
                <path d="M7 21H5a2 2 0 0 1-2-2v-2" />
                <line x1="7" y1="12" x2="17" y2="12" />
              </svg>
              <span>{scanResult ? "NEXT PASS" : "SCAN"}</span>
            </button>

            {/* Reset Button */}
            {scanResult && (
              <button
                type="button"
                onClick={handleReset}
                className="rounded-sm border border-foreground/50 bg-secondary px-2 py-1 text-xs font-bold text-foreground hover:bg-muted cursor-pointer transition-colors"
              >
                RESET
              </button>
            )}
          </div>

          <div className="flex items-center gap-1.5">
            {/* Laser Beam Toggle */}
            <button
              type="button"
              onClick={() => setLaserActive(!laserActive)}
              className={cn(
                "rounded-sm border px-2 py-1 text-[10px] font-bold uppercase transition-colors cursor-pointer",
                laserActive
                  ? "border-destructive bg-destructive/10 text-destructive"
                  : "border-border text-muted-foreground hover:text-foreground"
              )}
              title="Toggle laser sweep line"
            >
              LASER: {laserActive ? "ON" : "OFF"}
            </button>

            {/* Torch Flash Toggle */}
            <button
              type="button"
              onClick={() => setTorchOn(!torchOn)}
              className={cn(
                "rounded-sm border px-2 py-1 text-[10px] font-bold uppercase transition-colors cursor-pointer",
                torchOn
                  ? "border-amber-500 bg-amber-500/20 text-amber-600 dark:text-amber-400"
                  : "border-border text-muted-foreground hover:text-foreground"
              )}
              title="Toggle viewfinder torch light"
            >
              TORCH
            </button>

            {/* Camera Switch */}
            <button
              type="button"
              onClick={cycleCamera}
              className="rounded-sm border border-border px-2 py-1 text-[10px] font-bold text-muted-foreground hover:text-foreground cursor-pointer transition-colors uppercase"
              title="Switch camera lens"
            >
              {cameraMode.split(" ")[0]}
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

Props

PropTypeDescription
titlestringHeader title on turnstile scanner frame.
gateIdstringGate and turnstile identifier label.
activebooleanWhether camera viewfinder is scanning.
onScan(data: TicketScanData) => voidCallback when ticket is detected.
showLaserbooleanDisplays animated red laser sweep line.