Browser-Native QR Code Scanner with Scittle

Build a QR code scanner that runs entirely in your browser using Scittle and ClojureScript - no build tools, no backend, just pure browser magic!
Author
Published

November 8, 2025

Keywords

qr-code, scanner, camera, webrtc, scittle, browser-native, getUserMedia, jsQR

Browser-Native QR Code Scanner with Scittle

About This Project

This is part of my ongoing exploration of browser-native development with Scittle. In my previous articles on Building Browser-Native Presentations, Python + ClojureScript Integration, and Free Weather Data with NWS API, I’ve been demonstrating how to create powerful, interactive applications without any build tools.

Today, I’m excited to show you how to build a QR code scanner that runs entirely in your browser using:

  • Scittle - ClojureScript interpreter (no compilation needed!)
  • Reagent - React wrapper for ClojureScript
  • jsQR - Pure JavaScript QR code library
  • WebRTC - Native browser camera access
  • Canvas API - Image processing

What makes this special? Zero backend, zero build tools, zero configuration. Just open an HTML file in your browser and start scanning QR codes!

Why QR Codes?

QR codes are everywhere in our daily lives:

  • 📱 Mobile payments - Venmo, PayPal, Cash App
  • 🍔 Restaurant menus - Contactless ordering
  • 🎫 Event tickets - Concerts, movies, flights
  • 📦 Package tracking - Shipping labels, inventory
  • 🔐 Authentication - 2FA setup, WiFi passwords
  • 🌐 Website links - Marketing, product info

Having the ability to scan and decode QR codes directly in a web application opens up countless possibilities for:

  • Inventory management systems
  • Event check-in applications
  • Product information displays
  • Authentication flows
  • Contact information sharing
  • And much more!

How QR Code Scanning Works

Let’s understand the technology behind QR code scanning in a browser:

graph TB A[User clicks Start Camera] --> B[Request Camera Permission] B --> C{Permission Granted?} C -->|Yes| D[getUserMedia - Video Stream] C -->|No| E[Show Error] D --> F[Video Element Plays Stream] F --> G[Click Start Scanning] G --> H[500ms Interval Loop] H --> I[Capture Frame to Canvas] I --> J[Get ImageData from Canvas] J --> K[jsQR Library Decodes QR] K --> L{QR Code Found?} L -->|Yes| M[Store Result if Not Duplicate] L -->|No| H M --> N[Display in Results List] N --> H

The Technology Stack

1. WebRTC getUserMedia API

Modern browsers provide access to camera and microphone through the getUserMedia API:

;; Request camera access
(js/navigator.mediaDevices.getUserMedia
  #js {:video true :audio false})

This is the same technology used by:

  • Video conferencing apps (Zoom, Google Meet)
  • Social media camera features (Instagram, Snapchat)
  • WebRTC peer-to-peer video calls

2. HTML5 Canvas API

The Canvas API allows us to:

  • Capture video frames as images
  • Extract pixel data for processing
  • Draw overlays and visual feedback
;; Capture video frame to canvas
(let [ctx (.getContext canvas "2d")]
  (.drawImage ctx video-element 0 0 width height)
  (let [image-data (.getImageData ctx 0 0 width height)]
    ;; Now we have raw pixel data for QR detection
    ))

3. jsQR Library

jsQR is a pure JavaScript QR code detection library that:

  • Works entirely in the browser (no server needed)
  • Processes ImageData from canvas
  • Returns decoded QR code content
  • Handles various QR code versions and error correction levels
;; Decode QR code from image data
(let [code (js/jsQR pixel-data width height)]
  (when code
    (let [decoded-text (.-data code)]
      ;; Use the decoded text
      )))

Browser Compatibility

This QR scanner works in all modern browsers:

  • Chrome/Edge - Full support
  • Firefox - Full support
  • Safari - Full support (iOS 11+)
  • Opera - Full support
  • ⚠️ Internet Explorer - Not supported (no getUserMedia)

Mobile Support

The scanner works great on mobile devices:

  • iOS Safari - Works with camera permission
  • Android Chrome - Works with camera permission
  • Mobile Firefox - Works with camera permission

The key is using playsInline attribute on the video element to prevent fullscreen video on iOS.

Understanding the Code Architecture

Let’s break down how our QR scanner is structured:

State Management

We use a single Reagent atom to manage all application state:

(defonce app-state
  (r/atom
   {:stream nil              ; Camera MediaStream
    :qr-scanning? false      ; Scanning active?
    :qr-results []           ; Array of scanned results
    :qr-scanner-interval nil ; setInterval ID
    :video-playing? false    ; Video ready?
    :error nil}))            ; Error message

This simple state management pattern:

  • Keeps everything in one place
  • Makes state updates predictable
  • Works perfectly with Reagent’s reactivity

Keyword Arguments Pattern

Throughout the code, we use keyword arguments for better readability:

;; Traditional positional arguments (harder to read)
(start-stream on-success-fn on-error-fn)

;; Keyword arguments (self-documenting!)
(start-stream!
  {:on-success (fn [stream] ...)
   :on-error (fn [error] ...)})

Benefits of keyword arguments:

  • Self-documenting - Clear what each parameter does
  • Flexible - Order doesn’t matter
  • Optional parameters - Easy to add defaults
  • Future-proof - Add new options without breaking existing code

Core Functions

The scanner has several key functions:

Camera Management:

  • start-stream! - Request camera access with callbacks
  • stop-stream! - Clean up camera resources

Scanning Functions:

  • scan-qr-code - Capture frame and detect QR codes
  • start-qr-scanning! - Begin continuous scanning loop
  • stop-qr-scanning! - Stop the scanning loop

Utility Functions:

  • copy-to-clipboard! - Copy results to clipboard
  • clear-qr-results! - Clear all scanned results
  • generate-id - Create unique IDs for results
  • format-timestamp - Format scan timestamps

Privacy and Security

When building camera applications, privacy is crucial:

🔒 What We Do Right:

  • Permission-based - Camera only activates after user approval
  • Client-side only - No data sent to servers
  • Visual feedback - Clear indicators when camera is active
  • Easy control - Start/Stop buttons clearly visible
  • No storage - Results cleared when you refresh the page

💡 Best Practices:

  1. Always request permission before camera access
  2. Provide clear visual feedback when camera is active
  3. Give users easy ways to stop the camera
  4. Don’t automatically start camera without user action
  5. Explain what you’re doing with the data

The Complete Implementation

Here’s our full QR scanner implementation with keyword arguments throughout:

(ns scittle.qrcode.qr-scanner
  "QR Code Scanner - Browser-native QR code scanning with Scittle"
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]))

;; ============================================================================
;; State Management
;; ============================================================================

(defonce app-state
  (r/atom
   {:stream nil
    :qr-scanning? false
    :qr-results []
    :qr-scanner-interval nil
    :video-playing? false
    :error nil
    :scan-flash? false
    :toast-message nil
    :latest-result-id nil}))

;; ============================================================================
;; Utility Functions
;; ============================================================================

(defn generate-id
  "Generate a unique ID for QR scan results"
  []
  (str (random-uuid)))

(defn format-timestamp
  "Format a timestamp into a readable string"
  [timestamp]
  (.toLocaleString (js/Date. timestamp)))

(defn get-video-constraints
  "Get getUserMedia constraints for video-only capture"
  []
  #js {:video true :audio false})

;; ============================================================================
;; Visual Feedback Functions
;; ============================================================================

(defn show-scan-flash!
  "Show a brief green flash to indicate successful scan"
  []
  (swap! app-state assoc :scan-flash? true)
  (js/setTimeout #(swap! app-state assoc :scan-flash? false) 300))

(defn show-toast!
  "Show a success toast message

  Usage:
    (show-toast! {:message \"QR Code detected!\"})"
  [{:keys [message duration]}]
  (let [display-duration (or duration 2000)]
    (swap! app-state assoc :toast-message message)
    (js/setTimeout #(swap! app-state assoc :toast-message nil) display-duration)))

(defn play-beep!
  "Play a simple beep sound using Web Audio API"
  []
  (try
    (let [audio-ctx (or (js/AudioContext.) (js/webkitAudioContext.))
          oscillator (.createOscillator audio-ctx)
          gain (.createGain audio-ctx)]
      (set! (.-type oscillator) "sine")
      (set! (.-frequency.value oscillator) 800)
      (set! (.-value (.-gain gain)) 0.3)
      (.connect oscillator gain)
      (.connect gain (.-destination audio-ctx))
      (.start oscillator)
      (.stop oscillator (+ (.-currentTime audio-ctx) 0.1)))
    (catch js/Error e
      ;; Silently fail if audio not available
      nil)))

;; ============================================================================
;; Media Stream Management
;; ============================================================================

(defn stop-stream!
  "Stop the current media stream and clean up"
  []
  (when-let [stream (:stream @app-state)]
    (doseq [track (.getTracks stream)]
      (.stop track))
    (swap! app-state assoc :stream nil :video-playing? false)))

(defn start-stream!
  "Start camera stream

  Usage:
    (start-stream!
      {:on-success (fn [stream] ...)
       :on-error (fn [error] ...)})"
  [{:keys [on-success on-error]}]
  (stop-stream!)
  (-> (js/navigator.mediaDevices.getUserMedia (get-video-constraints))
      (.then (fn [stream]
               (swap! app-state assoc :stream stream :error nil)
               (when on-success (on-success stream)))
             (fn [err]
               (let [error-msg (str "Failed to start camera: " (.-message err))]
                 (swap! app-state assoc :error error-msg)
                 (when on-error (on-error error-msg)))))))

;; ============================================================================
;; QR Code Scanning Functions
;; ============================================================================

(defn scan-qr-code
  "Scan for QR code in video feed

  Usage:
    (scan-qr-code
      {:video-element video-el
       :canvas-element canvas-el
       :on-success (fn [qr-data] ...)})"
  [{:keys [video-element canvas-element on-success]}]
  (when (and video-element canvas-element)
    (let [ctx (.getContext canvas-element "2d" #js {:willReadFrequently true})
          width (.-videoWidth video-element)
          height (.-videoHeight video-element)]
      (when (and (> width 0) (> height 0))
        (set! (.-width canvas-element) width)
        (set! (.-height canvas-element) height)
        (.drawImage ctx video-element 0 0 width height)
        (let [image-data (.getImageData ctx 0 0 width height)
              code (js/jsQR (.-data image-data) width height)]
          (when code
            (let [qr-data (.-data code)
                  existing (some #(= (:data %) qr-data) (:qr-results @app-state))]
              (when-not existing
                (let [new-result {:id (generate-id)
                                  :data qr-data
                                  :timestamp (js/Date.now)}]
                  ;; Visual feedback
                  (show-scan-flash!)
                  (show-toast! {:message "✅ QR Code detected!"})
                  (play-beep!)

                  ;; Store result
                  (swap! app-state assoc :latest-result-id (:id new-result))
                  (swap! app-state update :qr-results conj new-result)

                  ;; Clear highlight after 2 seconds
                  (js/setTimeout #(swap! app-state assoc :latest-result-id nil) 2000)

                  (when on-success (on-success new-result)))))))))))

(defn start-qr-scanning!
  "Start continuous QR code scanning

  Usage:
    (start-qr-scanning!
      {:video-element video-el
       :canvas-element canvas-el
       :on-scan (fn [result] ...)})"
  [{:keys [video-element canvas-element on-scan]}]
  (when-not (:qr-scanning? @app-state)
    (let [scan-fn #(scan-qr-code {:video-element video-element
                                  :canvas-element canvas-element
                                  :on-success on-scan})
          interval-id (js/setInterval scan-fn 500)]
      (swap! app-state assoc
             :qr-scanning? true
             :qr-scanner-interval interval-id))))

(defn stop-qr-scanning!
  "Stop QR code scanning"
  []
  (when-let [interval-id (:qr-scanner-interval @app-state)]
    (js/clearInterval interval-id))
  (swap! app-state assoc
         :qr-scanning? false
         :qr-scanner-interval nil))

(defn clear-qr-results!
  "Clear all scanned QR results"
  []
  (swap! app-state assoc :qr-results []))

;; ============================================================================
;; Clipboard Functions
;; ============================================================================

(defn copy-to-clipboard!
  "Copy text to clipboard

  Usage:
    (copy-to-clipboard!
      {:text \"Hello World\"
       :on-success (fn [] ...)
       :on-error (fn [error] ...)})"
  [{:keys [text on-success on-error]}]
  (-> (js/navigator.clipboard.writeText text)
      (.then (fn []
               (when on-success (on-success)))
             (fn [err]
               (when on-error (on-error err))))))

;; ============================================================================
;; UI Components
;; ============================================================================

(defn qr-results-display
  "Display scanned QR code results"
  []
  (let [results (:qr-results @app-state)
        latest-id (:latest-result-id @app-state)]
    [:div.qr-results
     [:h3 {:style {:margin-top "1.5rem"
                   :margin-bottom "1rem"
                   :font-size "1.25rem"
                   :font-weight "600"}}
      "Scanned QR Codes"]
     [:button
      {:on-click clear-qr-results!
       :disabled (empty? results)
       :style {:padding "0.5rem 1rem"
               :margin-bottom "1rem"
               :background-color (if (empty? results) "#ccc" "#dc3545")
               :color "white"
               :border "none"
               :border-radius "4px"
               :cursor (if (empty? results) "not-allowed" "pointer")
               :font-size "0.875rem"}}
      "Clear All"]
     (if (empty? results)
       [:p {:style {:color "#6c757d"
                    :font-style "italic"}}
        "No QR codes scanned yet"]
       [:div.results-list
        (for [result (reverse results)]
          (let [is-latest? (= (:id result) latest-id)]
            ^{:key (:id result)}
            [:div.qr-result-item
             {:style {:margin-bottom "0.75rem"
                      :padding "1rem"
                      :border (if is-latest? "2px solid #198754" "1px solid #ddd")
                      :border-radius "6px"
                      :word-break "break-all"
                      :background-color (if is-latest? "#d1e7dd" "#f8f9fa")
                      :transition "all 0.3s ease"
                      :box-shadow (if is-latest? "0 0 10px rgba(25, 135, 84, 0.3)" "none")}}
             [:div {:style {:display "flex"
                            :justify-content "space-between"
                            :align-items "flex-start"
                            :gap "1rem"}}
              [:div {:style {:flex "1"}}
               (when is-latest?
                 [:div {:style {:color "#198754"
                                :font-weight "bold"
                                :font-size "0.75rem"
                                :margin-bottom "0.5rem"}}
                  "✨ NEW"])
               [:code {:style {:display "block"
                               :padding "0.5rem"
                               :background-color "white"
                               :border "1px solid #e9ecef"
                               :border-radius "4px"
                               :font-size "0.875rem"
                               :overflow-wrap "break-word"}}
                (:data result)]
               [:div {:style {:margin-top "0.5rem"}}
                [:small {:style {:color "#6c757d"
                                 :font-size "0.75rem"}}
                 (format-timestamp (:timestamp result))]]]
              [:button
               {:on-click #(copy-to-clipboard!
                            {:text (:data result)
                             :on-success (fn [] (js/alert "Copied to clipboard!"))
                             :on-error (fn [_] (js/alert "Failed to copy"))})
                :style {:padding "0.5rem 1rem"
                        :background-color "#0d6efd"
                        :color "white"
                        :border "none"
                        :border-radius "4px"
                        :cursor "pointer"
                        :font-size "0.875rem"
                        :white-space "nowrap"
                        :flex-shrink "0"}}
               "Copy"]]]))])]))

(defn qr-scanner-component
  "Main QR scanner component"
  []
  (let [state @app-state]
    [:div.qr-scanner
     {:style {:max-width "800px"
              :margin "0 auto"
              :padding "1rem"
              :position "relative"}}

     ;; Toast notification
     (when (:toast-message state)
       [:div.toast
        {:style {:position "fixed"
                 :top "2rem"
                 :right "2rem"
                 :padding "1rem 1.5rem"
                 :background-color "#198754"
                 :color "white"
                 :border-radius "8px"
                 :box-shadow "0 4px 12px rgba(0,0,0,0.15)"
                 :font-weight "600"
                 :z-index "9999"
                 :animation "slideIn 0.3s ease-out"}}
        (:toast-message state)])

     ;; Title
     [:h2 {:style {:margin-bottom "1.5rem"
                   :font-size "1.75rem"
                   :font-weight "700"
                   :color "#212529"}}
      "📷 QR Code Scanner"]

     ;; Error display
     (when (:error state)
       [:div {:style {:padding "1rem"
                      :margin-bottom "1rem"
                      :background-color "#f8d7da"
                      :border "1px solid #f5c2c7"
                      :border-radius "6px"
                      :color "#842029"}}
        [:strong "Error: "] (:error state)])

     ;; Control buttons
     [:div.controls
      {:style {:display "flex"
               :gap "0.5rem"
               :flex-wrap "wrap"
               :margin-bottom "1rem"}}
      [:button
       {:on-click #(start-stream! {})
        :disabled (boolean (:stream state))
        :style {:padding "0.75rem 1.5rem"
                :background-color (if (:stream state) "#6c757d" "#198754")
                :color "white"
                :border "none"
                :border-radius "6px"
                :cursor (if (:stream state) "not-allowed" "pointer")
                :font-size "1rem"
                :font-weight "500"}}
       "🎥 Start Camera"]

      [:button
       {:on-click (fn []
                    (when-let [video (js/document.getElementById "qr-video")]
                      (when-let [canvas (js/document.getElementById "qr-canvas")]
                        (start-qr-scanning! {:video-element video
                                             :canvas-element canvas}))))
        :disabled (or (not (:stream state)) (:qr-scanning? state))
        :style {:padding "0.75rem 1.5rem"
                :background-color (if (or (not (:stream state)) (:qr-scanning? state))
                                    "#6c757d"
                                    "#0d6efd")
                :color "white"
                :border "none"
                :border-radius "6px"
                :cursor (if (or (not (:stream state)) (:qr-scanning? state))
                          "not-allowed"
                          "pointer")
                :font-size "1rem"
                :font-weight "500"}}
       "🔍 Start Scanning"]

      [:button
       {:on-click stop-qr-scanning!
        :disabled (not (:qr-scanning? state))
        :style {:padding "0.75rem 1.5rem"
                :background-color (if (:qr-scanning? state) "#ffc107" "#6c757d")
                :color (if (:qr-scanning? state) "#000" "white")
                :border "none"
                :border-radius "6px"
                :cursor (if (:qr-scanning? state) "pointer" "not-allowed")
                :font-size "1rem"
                :font-weight "500"}}
       "⏸️ Stop Scanning"]

      [:button
       {:on-click (fn []
                    (stop-qr-scanning!)
                    (stop-stream!))
        :disabled (not (:stream state))
        :style {:padding "0.75rem 1.5rem"
                :background-color (if (:stream state) "#dc3545" "#6c757d")
                :color "white"
                :border "none"
                :border-radius "6px"
                :cursor (if (:stream state) "pointer" "not-allowed")
                :font-size "1rem"
                :font-weight "500"}}
       "🛑 Stop Camera"]]

     ;; Scanning indicator
     (when (:qr-scanning? state)
       [:div.scanning-indicator
        {:style {:padding "0.75rem"
                 :margin-bottom "1rem"
                 :background-color "#d1e7dd"
                 :border "1px solid #badbcc"
                 :border-radius "6px"
                 :color "#0f5132"
                 :font-weight "600"
                 :text-align "center"}}
        "📡 Scanning for QR codes..."])

     ;; Video container with flash effect
     [:div.video-container
      {:style {:position "relative"
               :margin-bottom "1.5rem"
               :border-radius "8px"
               :overflow "hidden"
               :background-color "#000"
               :box-shadow (if (:scan-flash? state)
                             "0 0 30px rgba(25, 135, 84, 0.8)"
                             "none")
               :transition "box-shadow 0.3s ease"}}
      (when (:scan-flash? state)
        [:div {:style {:position "absolute"
                       :top "0"
                       :left "0"
                       :right "0"
                       :bottom "0"
                       :background-color "rgba(25, 135, 84, 0.3)"
                       :z-index "10"
                       :pointer-events "none"}}])
      (when (:stream state)
        [:div
         [:video#qr-video
          {:ref (fn [el]
                  (when (and el (:stream state))
                    (set! (.-srcObject el) (:stream state))
                    (when-not (:video-playing? state)
                      (.then (.play el)
                             #(swap! app-state assoc :video-playing? true)
                             #(swap! app-state assoc
                                     :error (str "Video play error: " %))))))
           :autoPlay true
           :playsInline true
           :muted true
           :style {:width "100%"
                   :max-width "100%"
                   :display "block"
                   :border-radius "8px"}}]
         [:canvas#qr-canvas
          {:style {:display "none"}}]])]

     ;; Results display
     [qr-results-display]

     ;; Instructions
     [:div.info
      {:style {:margin-top "2rem"
               :padding "1rem"
               :background-color "#e7f3ff"
               :border-left "4px solid #0d6efd"
               :border-radius "4px"}}
      [:p {:style {:margin "0"
                   :color "#084298"
                   :font-size "0.875rem"}}
       "💡 " [:strong "How to use:"]
       " Click 'Start Camera' to access your webcam, then click 'Start Scanning'. "
       "Point your camera at any QR code and it will be automatically detected and decoded. "
       "You can copy the results to your clipboard by clicking the 'Copy' button."]]]))

;; ============================================================================
;; Mount point
;; ============================================================================

(defn ^:export mount-qr-scanner
  "Mount the QR scanner component to the DOM"
  []
  (when-let [el (js/document.getElementById "qr-scanner-app")]
    (rdom/render [qr-scanner-component] el)))

;; Auto-mount when script loads
(mount-qr-scanner)

Try It Live!

Ready to scan some QR codes? The scanner is embedded below. Here’s how to use it:

Step-by-step guide:

  1. Click “🎥 Start Camera” - Your browser will ask for camera permission
  2. Grant permission - Allow camera access when prompted
  3. Click “🔍 Start Scanning” - The scanner will begin analyzing frames
  4. Point at QR code - Hold a QR code in front of your camera
  5. View results - Decoded content appears below automatically
  6. Copy to clipboard - Click “Copy” button to copy any result

Tips for best results:

  • 💡 Good lighting helps QR detection
  • 📏 Keep QR code in the center of frame
  • 🎯 Hold steady for a second to ensure detection
  • 📱 Works great on mobile devices too!

Need a QR code to test? Try these:

  • Generate one at qr-code-generator.com
  • Use your phone’s WiFi QR share feature
  • Many products have QR codes on their packaging
  • Your phone’s wallet apps likely have QR codes

Or scan these sample QR codes right now:

ClojureCivitas Blog - Browse our collection of Clojure articles and tutorials:

(kind/hiccup
 [:div {:style {:margin "1rem 0"}}
  [:img {:src "clojurecivitas-link.png"
         :alt "ClojureCivitas Blog QR Code"
         :style {:max-width "200px"
                 :height "auto"
                 :border "1px solid #ddd"
                 :border-radius "8px"
                 :padding "0.5rem"
                 :background "white"}}]])
ClojureCivitas Blog QR Code

Link: https://clojurecivitas.github.io/posts.html

Clojure/conj 2025 - Join us at the premier Clojure conference:

(kind/hiccup
 [:div {:style {:margin "1rem 0"}}
  [:img {:src "clojure-conj-link.png"
         :alt "Clojure/conj 2025 QR Code"
         :style {:max-width "200px"
                 :height "auto"
                 :border "1px solid #ddd"
                 :border-radius "8px"
                 :padding "0.5rem"
                 :background "white"}}]])
Clojure/conj 2025 QR Code

Link: https://2025.clojure-conj.org/

(kind/hiccup
 [:div {:style {:margin "2rem 0"
                :padding "2rem"
                :border "2px solid #e9ecef"
                :border-radius "8px"
                :background-color "#f8f9fa"}}
  [:div#qr-scanner-app {:style {:min-height "600px"}}]
  [:script {:src "https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js"}]
  [:script {:type "application/x-scittle"
            :src "qr_scanner.cljs"}]])

Real-World Use Cases

Now that you’ve seen how it works, here are some practical applications:

1. Event Check-in System

;; Example: Track event attendees
(defn event-checkin []
  (let [attendees (r/atom #{})]
    (fn []
      [:div
       [qr-scanner-component
        {:on-scan (fn [result]
                   (let [ticket-id (:data result)]
                     (if (@attendees ticket-id)
                       (js/alert "Already checked in!")
                       (do
                         (swap! attendees conj ticket-id)
                         (js/alert "Welcome!")))))}]
       [:p "Checked in: " (count @attendees)]])))

2. Inventory Management

;; Example: Scan product barcodes/QR codes
(defn inventory-scanner []
  (let [items (r/atom [])]
    (fn []
      [:div
       [qr-scanner-component
        {:on-scan (fn [result]
                   (swap! items conj
                          {:code (:data result)
                           :time (js/Date.now)}))}]
       [:h3 "Scanned Items: " (count @items)]
       (for [item @items]
         ^{:key (:time item)}
         [:div (:code item)])])))

3. Authentication/2FA Setup

;; Example: Scan 2FA QR codes
(defn twofa-setup []
  (let [secret (r/atom nil)]
    (fn []
      [:div
       (if @secret
         [:p "Secret key: " @secret]
         [qr-scanner-component
          {:on-scan (fn [result]
                     ;; Parse otpauth:// URLs
                     (reset! secret (:data result)))}])])))

4. WiFi Network Sharing

;; Example: Parse WiFi QR codes
(defn parse-wifi-qr [text]
  ;; Format: WIFI:T:WPA;S:NetworkName;P:Password;;
  (when (clojure.string/starts-with? text "WIFI:")
    (let [parts (clojure.string/split text #";")
          parse-part (fn [part]
                      (let [[k v] (clojure.string/split part #":")]
                        {(keyword k) v}))]
      (apply merge (map parse-part parts)))))

(defn wifi-scanner []
  [qr-scanner-component
   {:on-scan (fn [result]
              (when-let [wifi (parse-wifi-qr (:data result))]
                (js/alert (str "Network: " (:S wifi)))))}])

Extending the Scanner

Here are some ideas to enhance the QR scanner:

Add History Persistence

Store scan history in localStorage:

(defn save-to-localstorage! [results]
  (.setItem js/localStorage
            "qr-history"
            (js/JSON.stringify (clj->js results))))

(defn load-from-localstorage []
  (when-let [stored (.getItem js/localStorage "qr-history")]
    (js->clj (js/JSON.parse stored) :keywordize-keys true)))

Add Visual Feedback

Highlight detected QR codes with canvas overlay:

(defn draw-qr-overlay [ctx code]
  (let [location (.-location code)
        tl (.-topLeftCorner location)
        tr (.-topRightCorner location)
        br (.-bottomRightCorner location)
        bl (.-bottomLeftCorner location)]
    (set! (.-strokeStyle ctx) "#00ff00")
    (set! (.-lineWidth ctx) 4)
    (.beginPath ctx)
    (.moveTo ctx (.-x tl) (.-y tl))
    (.lineTo ctx (.-x tr) (.-y tr))
    (.lineTo ctx (.-x br) (.-y br))
    (.lineTo ctx (.-x bl) (.-y bl))
    (.closePath ctx)
    (.stroke ctx)))

Add Sound Effects

Play a sound when QR code is detected:

(defn play-scan-sound []
  (let [audio (js/Audio. "scan-beep.mp3")]
    (.play audio)))

(defn scan-qr-code [{:keys [video-element canvas-element on-success]}]
  (when (and video-element canvas-element)
    ;; ... scanning logic ...
    (when code
      (play-scan-sound)  ; Add sound feedback
      (on-success new-result))))

Add Export Functionality

Export scanned results as CSV or JSON:

(defn export-as-csv [results]
  (let [csv (str "Timestamp,Data\n"
                 (clojure.string/join "\n"
                   (map (fn [r]
                         (str (:timestamp r) "," (:data r)))
                        results)))
        blob (js/Blob. [csv] #js {:type "text/csv"})
        url (.createObjectURL js/URL blob)
        a (.createElement js/document "a")]
    (set! (.-href a) url)
    (set! (.-download a) "qr-results.csv")
    (.click a)))

Add QR Code Generation

Use a library like qrcode.js to generate QR codes:

;; Using QRCode.js library
(defn generate-qr-code [text container-id]
  (js/QRCode. (.getElementById js/document container-id)
              #js {:text text
                   :width 256
                   :height 256}))

Performance Considerations

When building real-time camera applications, performance matters:

Optimization Tips:

  1. Scan interval - We scan every 500ms. Adjust based on your needs:
    • Faster (100-200ms) = More responsive but higher CPU usage
    • Slower (1000ms) = Lower CPU usage but might miss quick scans
  2. Canvas size - Smaller canvas = faster processing:
;; Scale down for faster processing
(defn scan-qr-code [{:keys [video-element canvas-element]}]
  (let [scale 0.5  ; Process at 50% resolution
        width (* scale (.-videoWidth video-element))
        height (* scale (.-videoHeight video-element))]
    ;; ... rest of scanning logic ...))
  1. Duplicate detection - We check for duplicates before adding results:
(let [existing (some #(= (:data %) qr-data)
                       (:qr-results @app-state))]
   (when-not existing
     ;; Only add if not duplicate
     (swap! app-state update :qr-results conj new-result)))
  1. Cleanup - Always stop intervals and streams when done:
(defn cleanup! []
  (stop-qr-scanning!)
  (stop-stream!))

Browser DevTools Tips

When debugging camera applications:

Chrome DevTools:

  1. Simulate camera - Settings → Content Settings → Camera
  2. Inspect video - Right-click video element → Inspect
  3. Network throttling - Test on slower connections
  4. Mobile emulation - Test responsive behavior

Firefox DevTools:

  1. Camera permissions - about:permissions
  2. Console logging - Monitor camera events
  3. Responsive design mode - Test mobile layouts

Wrapping Up

What We’ve Built:

We created a complete QR code scanner that:

  • ✅ Runs entirely in the browser (no backend!)
  • ✅ Uses zero build tools (pure Scittle!)
  • ✅ Has clean keyword-argument APIs
  • ✅ Works on desktop and mobile
  • ✅ Respects user privacy
  • ✅ Is fully extensible

Key Takeaways:

  1. Browser APIs are powerful - Camera, Canvas, Clipboard, etc.
  2. Scittle removes friction - No compilation, instant feedback
  3. Keyword arguments improve clarity - Self-documenting code
  4. Privacy matters - Always be transparent with camera usage
  5. Real-world applications - QR codes are everywhere!

What’s Next?

You can extend this scanner in many ways:

  • Add barcode support (using different detection library)
  • Implement history and favorites
  • Add URL preview before opening links
  • Create multi-scanner for bulk operations
  • Add AR overlays and animations
  • Integrate with your backend APIs

Resources:

Thank You!

I hope this tutorial inspires you to build your own camera-based applications. The combination of modern browser APIs, ClojureScript, and Scittle makes it incredibly easy to create powerful, interactive experiences without any build tools or backend infrastructure.

Have fun scanning! 📸✨


All source code is available in the article above. Copy, modify, and share freely!

Connect & Share

Questions or ideas? I’d love to hear what you build with this scanner! Connect with me to share your projects, ask questions, or just say hi:

(kind/hiccup
 [:div {:style {:margin "1.5rem 0"}}
  [:img {:src "linkedin-link.png"
         :alt "Connect on LinkedIn"
         :style {:max-width "200px"
                 :height "auto"
                 :border "1px solid #ddd"
                 :border-radius "8px"
                 :padding "0.5rem"
                 :background "white"}}]])
Connect on LinkedIn

Scan the QR code above or connect with me on LinkedIn - I’m always excited to discuss Clojure, browser-native development, and innovative web applications!

Share your QR scanner projects with the community and let’s build amazing things together! 🚀

source: src/scittle/qrcode/qr_code_scanner.clj