Skip to content

React & Vue ​

Scanic is framework-agnostic. It works with plain DOM, so it drops into any UI library. Below are idiomatic starting points for React and Vue. The pattern is always the same: turn a file into an HTMLImageElement, pass it to scanDocument, and render result.output.

Upload & scan ​

jsx
import { useState } from 'react'
import { scanDocument } from 'scanic'

export function DocumentScanner() {
  const [busy, setBusy] = useState(false)
  const [error, setError] = useState(null)
  const [output, setOutput] = useState(null)

  const onFile = (e) => {
    const file = e.target.files[0]
    if (!file) return
    const img = new Image()
    img.onload = async () => {
      setBusy(true)
      setError(null)
      try {
        const result = await scanDocument(img, { mode: 'extract', output: 'dataurl' })
        if (result.success) setOutput(result.output)
        else setError('No document detected')
      } catch (err) {
        setError(err.message)
      } finally {
        setBusy(false)
      }
    }
    img.src = URL.createObjectURL(file)
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={onFile} disabled={busy} />
      {busy && <p>Scanning…</p>}
      {error && <p>{error}</p>}
      {output && <img src={output} alt="Scanned document" />}
    </div>
  )
}
vue
<script setup>
import { ref } from 'vue'
import { scanDocument } from 'scanic'

const busy = ref(false)
const error = ref(null)
const output = ref(null)

const onFile = (e) => {
  const file = e.target.files[0]
  if (!file) return
  const img = new Image()
  img.onload = async () => {
    busy.value = true
    error.value = null
    try {
      const result = await scanDocument(img, { mode: 'extract', output: 'dataurl' })
      if (result.success) output.value = result.output
      else error.value = 'No document detected'
    } catch (err) {
      error.value = err.message
    } finally {
      busy.value = false
    }
  }
  img.src = URL.createObjectURL(file)
}
</script>

<template>
  <div>
    <input type="file" accept="image/*" @change="onFile" :disabled="busy" />
    <p v-if="busy">Scanning…</p>
    <p v-if="error">{{ error }}</p>
    <img v-if="output" :src="output" alt="Scanned document" />
  </div>
</template>

Live camera scanning ​

For webcam capture, hold a single Scanner instance across renders so WASM is initialized only once. The <video> is the preview, a transparent canvas on top holds the overlay, and detection runs about 10 times per second instead of on every animation frame. See Real-time scanning for why that matters on phones.

jsx
import { useRef, useState, useEffect } from 'react'
import { Scanner } from 'scanic'

const DETECT_INTERVAL_MS = 100 // about 10 detections per second

export function LiveScanner() {
  const videoRef = useRef(null)
  const overlayRef = useRef(null)
  const scannerRef = useRef(new Scanner())
  const stateRef = useRef({ stream: null, timer: 0, running: false })
  const [scanning, setScanning] = useState(false)

  const start = async () => {
    await scannerRef.current.initialize()
    const s = stateRef.current
    const video = videoRef.current
    s.stream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: 'environment' },
    })
    video.srcObject = s.stream
    await video.play()
    // Corners come back in video pixels, so the overlay uses the same size.
    overlayRef.current.width = video.videoWidth
    overlayRef.current.height = video.videoHeight
    s.running = true
    setScanning(true)
    detectLoop()
  }

  const detectLoop = async () => {
    const s = stateRef.current
    if (!s.running) return
    const started = performance.now()

    if (!document.hidden) {
      // Pass the video directly, no getImageData() copy per frame.
      const result = await scannerRef.current.scan(videoRef.current, { mode: 'detect' })
      if (!s.running) return
      const ctx = overlayRef.current.getContext('2d')
      ctx.clearRect(0, 0, overlayRef.current.width, overlayRef.current.height)
      if (result.success) drawQuad(ctx, result.corners)
    }

    const elapsed = performance.now() - started
    s.timer = setTimeout(detectLoop, Math.max(0, DETECT_INTERVAL_MS - elapsed))
  }

  const stop = () => {
    const s = stateRef.current
    s.running = false
    clearTimeout(s.timer)
    s.stream?.getTracks().forEach((t) => t.stop())
    setScanning(false)
  }

  useEffect(() => stop, [])

  return (
    <div>
      <button onClick={start} disabled={scanning}>Start</button>
      <button onClick={stop} disabled={!scanning}>Stop</button>
      <div style={{ position: 'relative' }}>
        <video ref={videoRef} playsInline muted style={{ width: '100%', display: 'block' }} />
        <canvas
          ref={overlayRef}
          style={{ position: 'absolute', inset: 0, width: '100%', height: '100%' }}
        />
      </div>
    </div>
  )
}

function drawQuad(ctx, c) {
  ctx.strokeStyle = '#22c55e'
  ctx.lineWidth = 3
  ctx.beginPath()
  ctx.moveTo(c.topLeft.x, c.topLeft.y)
  ctx.lineTo(c.topRight.x, c.topRight.y)
  ctx.lineTo(c.bottomRight.x, c.bottomRight.y)
  ctx.lineTo(c.bottomLeft.x, c.bottomLeft.y)
  ctx.closePath()
  ctx.stroke()
}
vue
<script setup>
import { ref, onUnmounted } from 'vue'
import { Scanner } from 'scanic'

const DETECT_INTERVAL_MS = 100 // about 10 detections per second

const scanner = new Scanner()
const video = ref(null)
const overlay = ref(null)
const scanning = ref(false)
let stream = null, timer = 0, running = false

const start = async () => {
  await scanner.initialize()
  stream = await navigator.mediaDevices.getUserMedia({
    video: { facingMode: 'environment' },
  })
  video.value.srcObject = stream
  await video.value.play()
  // Corners come back in video pixels, so the overlay uses the same size.
  overlay.value.width = video.value.videoWidth
  overlay.value.height = video.value.videoHeight
  running = true
  scanning.value = true
  detectLoop()
}

const detectLoop = async () => {
  if (!running) return
  const started = performance.now()

  if (!document.hidden) {
    // Pass the video directly, no getImageData() copy per frame.
    const result = await scanner.scan(video.value, { mode: 'detect' })
    if (!running) return
    const ctx = overlay.value.getContext('2d')
    ctx.clearRect(0, 0, overlay.value.width, overlay.value.height)
    if (result.success) {
      const c = result.corners
      ctx.strokeStyle = '#22c55e'
      ctx.lineWidth = 3
      ctx.beginPath()
      ctx.moveTo(c.topLeft.x, c.topLeft.y)
      ctx.lineTo(c.topRight.x, c.topRight.y)
      ctx.lineTo(c.bottomRight.x, c.bottomRight.y)
      ctx.lineTo(c.bottomLeft.x, c.bottomLeft.y)
      ctx.closePath()
      ctx.stroke()
    }
  }

  const elapsed = performance.now() - started
  timer = setTimeout(detectLoop, Math.max(0, DETECT_INTERVAL_MS - elapsed))
}

const stop = () => {
  running = false
  clearTimeout(timer)
  stream?.getTracks().forEach((t) => t.stop())
  scanning.value = false
}

onUnmounted(stop)
</script>

<template>
  <div>
    <button @click="start" :disabled="scanning">Start</button>
    <button @click="stop" :disabled="!scanning">Stop</button>
    <div style="position: relative">
      <video ref="video" playsinline muted style="width: 100%; display: block" />
      <canvas ref="overlay" style="position: absolute; inset: 0; width: 100%; height: 100%" />
    </div>
  </div>
</template>

TIP

Want users to fine-tune detected corners before capture? Drop in the Corner Editor, it works in any framework.

Released under the MIT License.