# Scanner Upload

> A document scanner frame with corner guides and a scan beam, for IDs and receipts.

A shadcn/ui block from the [@uploadcn](https://uploadcn.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uploadcn/scanner-upload).

- Page, with a live preview: https://shadcn.exchange/r/uploadcn/scanner-upload
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uploadcn/scanner-upload-light-53a03c20facf7122.png
- Registry item: https://uploadcn.dev/r/new-york-v4/scanner-upload.json
- Categories: upload, fintech, animated
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://uploadcn.dev/r/new-york-v4/scanner-upload.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@uploadcn/core`, `@uploadcn/react`, `cn`, `motion`
- Registry items: `@uploadcn/upload`

## Files

### `registry/default/blocks/scanner-upload.tsx`

```tsx
"use client"

import * as React from "react"
import {
  type UploadAdapter,
  type Uploader,
  type UploadItem as UploadItemData,
} from "@uploadcn/core"
import {
  UploadItemContext,
  useUploadContext,
  useUploadSelector,
  type UploadRootProps,
} from "@uploadcn/react"
import { cn } from "cn"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { IconPlaceholder } from "@/components/icon-placeholder"
import {
  Upload,
  UploadDropzone,
  UploadItemMedia,
  UploadTrigger,
} from "@/registry/default/ui/upload"

interface ScannerUploadProps<TResult> {
  adapter?: UploadAdapter<TResult>
  /** Use an uploader you created with `useUploader` instead of options. */
  uploader?: Uploader<TResult>
  label?: string
  /** Default images and PDF. */
  accept?: string
  /** Default 15 MB. */
  maxSize?: number
  onSuccess?: (item: UploadItemData<TResult>) => void
  className?: string
}

/**
 * A document scanner frame for IDs, receipts and contracts: corner guides
 * lock on, a scan beam sweeps while it uploads, then the frame confirms.
 * Opens the rear camera on phones.
 */
function ScannerUpload<TResult = unknown>({
  adapter,
  uploader,
  label = "Scan a document",
  accept = "image/*,application/pdf",
  maxSize = 15 * 1000 * 1000,
  onSuccess,
  className,
}: ScannerUploadProps<TResult>) {
  return (
    <Upload
      {...((uploader
        ? { uploader }
        : {
            adapter,
            accept,
            maxFiles: 1,
            maxSize,
            onSuccess,
          }) as UploadRootProps<TResult>)}
      multiple={false}
      capture="environment"
      className={cn("items-center", className)}
    >
      <Scanner label={label} />
    </Upload>
  )
}

const CORNERS = [
  "top-0 left-0 border-t-4 border-l-4 rounded-tl-xl",
  "top-0 right-0 border-t-4 border-r-4 rounded-tr-xl",
  "bottom-0 left-0 border-b-4 border-l-4 rounded-bl-xl",
  "bottom-0 right-0 border-b-4 border-r-4 rounded-br-xl",
]

function Scanner({ label }: { label: string }) {
  const { uploader } = useUploadContext()
  const item = useUploadSelector(uploader, (state) =>
    state.items.find((entry) => entry.status !== "cancelled")
  )
  const reduceMotion = useReducedMotion()
  const scanning =
    !!item &&
    (item.status === "uploading" ||
      item.status === "queued" ||
      item.status === "processing" ||
      item.status === "scanning")
  const done = item?.status === "success"
  const failed = item?.status === "error" || item?.status === "rejected"

  return (
    <div className="flex w-full max-w-xs flex-col items-center gap-3">
      <UploadDropzone
        variant="muted"
        className="relative aspect-[1.586] w-full overflow-hidden rounded-2xl p-0"
        aria-label={label}
      >
        {/* Corner guides */}
        {CORNERS.map((corner, index) => (
          <motion.span
            key={index}
            aria-hidden
            className={cn(
              "absolute z-10 size-7 border-muted-foreground/50 transition-colors",
              corner,
              scanning && "border-primary",
              done && "border-primary",
              failed && "border-destructive"
            )}
            animate={
              reduceMotion ? undefined : { scale: scanning || done ? 0.92 : 1 }
            }
            transition={{ type: "spring", bounce: 0.4 }}
          />
        ))}
        {item && item.status !== "rejected" ? (
          <UploadItemContext.Provider value={item}>
            <UploadItemMedia
              variant="cover"
              className={cn(
                "transition-[filter] duration-500",
                scanning && "grayscale"
              )}
            />
          </UploadItemContext.Provider>
        ) : (
          <span className="flex flex-col items-center gap-2 text-muted-foreground">
            <IconPlaceholder
              lucide="ScanLineIcon"
              tabler="IconScan"
              hugeicons="DocumentValidationIcon"
              phosphor="ScanIcon"
              remixicon="RiScan2Line"
              className="size-7"
            />
            <span className="text-sm font-medium text-foreground">{label}</span>
            <span className="text-xs">Fit it inside the corners</span>
          </span>
        )}
        {/* Beam */}
        {scanning && !reduceMotion ? (
          <motion.span
            aria-hidden
            className="absolute inset-x-3 z-10 h-0.5 rounded-full bg-primary shadow-lg shadow-primary"
            initial={{ top: "8%" }}
            animate={{ top: ["8%", "92%", "8%"] }}
            transition={{ repeat: Infinity, duration: 2.2, ease: "easeInOut" }}
          />
        ) : null}
        <AnimatePresence>
          {done ? (
            <motion.span
              initial={{ opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={{ opacity: 0 }}
              className="absolute z-10 flex size-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg"
            >
              <IconPlaceholder
                lucide="CheckIcon"
                tabler="IconCheck"
                hugeicons="Tick02Icon"
                phosphor="CheckIcon"
                remixicon="RiCheckLine"
                className="size-6"
                strokeWidth={3}
              />
            </motion.span>
          ) : null}
        </AnimatePresence>
      </UploadDropzone>
      <div className="flex w-full items-center justify-between gap-2 text-sm">
        <span
          aria-live="polite"
          className={cn("text-muted-foreground", failed && "text-destructive")}
        >
          {scanning
            ? `Scanning… ${Math.round(item?.progress.percent ?? 0)}%`
            : done
              ? "Document captured"
              : failed
                ? (item?.issues[0]?.message ??
                  item?.error?.message ??
                  "Try again")
                : "Photo or PDF"}
        </span>
        {item ? (
          <UploadTrigger variant="ghost" size="sm">
            Rescan
          </UploadTrigger>
        ) : null}
      </div>
    </div>
  )
}

export { ScannerUpload, type ScannerUploadProps }
```
