Image Scanner
A dynamic component that applies interactive scanning effects to images, supporting various scan patterns, directions, and customizable.
ImageScanner

Click to Scan
Click on the image to trigger the scan effect

Repeating Scan
Continuously scans in a loop

Button Triggered
Click the button to trigger scanning
Installation Guide
1
Install Dependencies
Framer Motion
npm install framer-motion
Utility Functions
npm install clsx tailwind-merge
2
Setup Configuration
Create file:
/lib/utils.ts
/lib/utils.ts
Configuration1import { clsx, type ClassValue } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
3
Copy Component Code
Image Scanner.tsx
TypeScript1"use client";
2
3import { useState, useEffect, useRef } from "react";
4import { motion, AnimatePresence } from "framer-motion";
5import { cn } from "@/lib/utils";
6import Image from "next/image";
7
8export interface ImageScannerProps {
9 image: string;
10 alt?: string;
11 scanDirection?: "horizontal" | "vertical";
12 scanSpeed?: number;
13 scanColor?: "emerald" | "blue" | "purple" | "amber" | "red";
14 scanType?: "line" | "corners" | "both";
15 className?: string;
16 onScanComplete?: () => void;
17 autoScan?: boolean;
18 scanDelay?: number;
19 scanAtScroll?: boolean;
20 repeating?: boolean;
21 triggerScan?: boolean;
22}
23
24export const ImageScanner = ({
25 image,
26 alt = "Scanning image",
27 scanDirection = "horizontal",
28 scanSpeed = 2,
29 scanColor = "emerald",
30 scanType = "both",
31 className,
32 onScanComplete,
33 autoScan = false,
34 scanDelay = 0,
35 scanAtScroll = false,
36 repeating = false,
37 triggerScan = false,
38}: ImageScannerProps) => {
39 const [isScanning, setIsScanning] = useState(false);
40 const [scanComplete, setScanComplete] = useState(false);
41 const [hasScanned, setHasScanned] = useState(false);
42 const [scanCycle, setScanCycle] = useState(0);
43 const ref = useRef<HTMLDivElement>(null);
44 const scanTimer = useRef<NodeJS.Timeout | null>(null);
45 const completeTimer = useRef<NodeJS.Timeout | null>(null);
46
47 const colorMap = {
48 emerald: {
49 scan: "bg-emerald-500",
50 glow: "bg-emerald-500/20 dark:bg-emerald-500/10",
51 border: "border-emerald-500",
52 },
53 blue: {
54 scan: "bg-blue-500",
55 glow: "bg-blue-500/20 dark:bg-blue-500/10",
56 border: "border-blue-500",
57 },
58 purple: {
59 scan: "bg-purple-500",
60 glow: "bg-purple-500/20 dark:bg-purple-500/10",
61 border: "border-purple-500",
62 },
63 amber: {
64 scan: "bg-amber-500",
65 glow: "bg-amber-500/20 dark:bg-amber-500/10",
66 border: "border-amber-500",
67 },
68 red: {
69 scan: "bg-red-500",
70 glow: "bg-red-500/20 dark:bg-red-500/10",
71 border: "border-red-500",
72 },
73 };
74
75 const runScan = () => {
76 if (!isScanning) {
77 setIsScanning(true);
78 setScanCycle((prev) => prev + 1);
79
80 completeTimer.current = setTimeout(() => {
81 setScanComplete(true);
82 setHasScanned(true);
83 if (onScanComplete) onScanComplete();
84
85 setTimeout(() => {
86 setScanComplete(false);
87 setIsScanning(false);
88 if (repeating) {
89 scanTimer.current = setTimeout(runScan, 1000);
90 }
91 }, 1000);
92 }, scanSpeed * 1000);
93 }
94 };
95
96 useEffect(() => {
97 if (!scanAtScroll || !ref.current) return;
98
99 const observer = new IntersectionObserver(
100 (entries) => {
101 const [entry] = entries;
102 if (entry.isIntersecting && !hasScanned && !isScanning) {
103 runScan();
104 }
105 },
106 { threshold: 0.5 }
107 );
108
109 observer.observe(ref.current);
110 return () => observer.disconnect();
111 // eslint-disable-next-line react-hooks/exhaustive-deps
112 }, [scanAtScroll, hasScanned, isScanning]);
113
114 useEffect(() => {
115 if (autoScan && !hasScanned) {
116 scanTimer.current = setTimeout(runScan, scanDelay * 1000);
117 }
118
119 return () => {
120 if (scanTimer.current) clearTimeout(scanTimer.current);
121 if (completeTimer.current) clearTimeout(completeTimer.current);
122 };
123 // eslint-disable-next-line react-hooks/exhaustive-deps
124 }, [autoScan, scanDelay, hasScanned]);
125
126 useEffect(() => {
127 if (triggerScan && !isScanning) {
128 runScan();
129 }
130 // eslint-disable-next-line react-hooks/exhaustive-deps
131 }, [triggerScan]);
132
133 useEffect(() => {
134 if (repeating && !isScanning && !scanComplete) {
135 runScan();
136 }
137
138 return () => {
139 if (scanTimer.current) clearTimeout(scanTimer.current);
140 if (completeTimer.current) clearTimeout(completeTimer.current);
141 };
142 // eslint-disable-next-line react-hooks/exhaustive-deps
143 }, [repeating]);
144
145 useEffect(() => {
146 return () => {
147 if (scanTimer.current) clearTimeout(scanTimer.current);
148 if (completeTimer.current) clearTimeout(completeTimer.current);
149 };
150 }, []);
151
152 const startScan = () => {
153 if (!isScanning && !autoScan && !repeating) {
154 runScan();
155 }
156 };
157 const selectedColor = colorMap[scanColor] || colorMap.emerald;
158
159 return (
160 <div
161 ref={ref}
162 className={cn("relative overflow-hidden", className)}
163 onClick={!autoScan && !scanAtScroll && !repeating ? startScan : undefined}
164 >
165 <div
166 className={cn(
167 "w-full h-full relative overflow-hidden",
168 scanComplete ? "ring-2 ring-offset-2 dark:ring-offset-gray-900" : "",
169 scanComplete ? selectedColor.border : ""
170 )}
171 >
172 <Image
173 src={image || "/placeholder.svg"}
174 alt={alt}
175 height={500}
176 width={500}
177 quality={100}
178 className="w-full h-full object-cover"
179 />
180 <AnimatePresence mode="wait">
181 {isScanning && (
182 <>
183 {(scanType === "line" || scanType === "both") && (
184 <motion.div
185 key={`scanline-${scanCycle}`}
186 className={cn(
187 "absolute pointer-events-none",
188 scanDirection === "horizontal"
189 ? "left-0 right-0 h-1"
190 : "top-0 bottom-0 w-1",
191 selectedColor.scan
192 )}
193 initial={
194 scanDirection === "horizontal"
195 ? { top: 0, opacity: 0.7 }
196 : { left: 0, opacity: 0.7 }
197 }
198 animate={
199 scanDirection === "horizontal"
200 ? { top: "100%", opacity: 0.7 }
201 : { left: "100%", opacity: 0.7 }
202 }
203 exit={
204 scanDirection === "horizontal"
205 ? { top: "100%", opacity: 0 }
206 : { left: "100%", opacity: 0 }
207 }
208 transition={{
209 duration: scanSpeed,
210 ease: "linear",
211 }}
212 />
213 )}
214 {(scanType === "corners" || scanType === "both") && (
215 <>
216 <motion.div
217 key={`corner-tl-${scanCycle}`}
218 className={cn(
219 "absolute top-0 left-0 w-6 h-6 pointer-events-none",
220 "border-t-2 border-l-2",
221 selectedColor.border
222 )}
223 initial={{ opacity: 0 }}
224 animate={{ opacity: 1 }}
225 exit={{ opacity: 0 }}
226 transition={{ duration: 0.3 }}
227 />
228 <motion.div
229 key={`corner-tr-${scanCycle}`}
230 className={cn(
231 "absolute top-0 right-0 w-6 h-6 pointer-events-none",
232 "border-t-2 border-r-2",
233 selectedColor.border
234 )}
235 initial={{ opacity: 0 }}
236 animate={{ opacity: 1 }}
237 exit={{ opacity: 0 }}
238 transition={{ duration: 0.3 }}
239 />
240 <motion.div
241 key={`corner-bl-${scanCycle}`}
242 className={cn(
243 "absolute bottom-0 left-0 w-6 h-6 pointer-events-none",
244 "border-b-2 border-l-2",
245 selectedColor.border
246 )}
247 initial={{ opacity: 0 }}
248 animate={{ opacity: 1 }}
249 exit={{ opacity: 0 }}
250 transition={{ duration: 0.3 }}
251 />
252 <motion.div
253 key={`corner-br-${scanCycle}`}
254 className={cn(
255 "absolute bottom-0 right-0 w-6 h-6 pointer-events-none",
256 "border-b-2 border-r-2",
257 selectedColor.border
258 )}
259 initial={{ opacity: 0 }}
260 animate={{ opacity: 1 }}
261 exit={{ opacity: 0 }}
262 transition={{ duration: 0.3 }}
263 />
264 </>
265 )}
266 <motion.div
267 key={`glow-${scanCycle}`}
268 className={cn(
269 "absolute inset-0 pointer-events-none",
270 selectedColor.glow
271 )}
272 initial={{ opacity: 0 }}
273 animate={{
274 opacity: [0, 0.5, 0],
275 transition: {
276 repeat: 0,
277 duration: scanSpeed / 2,
278 repeatType: "reverse",
279 },
280 }}
281 exit={{ opacity: 0 }}
282 />
283 </>
284 )}
285 </AnimatePresence>
286 <AnimatePresence>
287 {scanComplete && (
288 <motion.div
289 className={cn(
290 "absolute inset-0 pointer-events-none",
291 selectedColor.glow
292 )}
293 initial={{ opacity: 0 }}
294 animate={{ opacity: 0.7 }}
295 exit={{ opacity: 0 }}
296 transition={{ duration: 0.3 }}
297 />
298 )}
299 </AnimatePresence>
300 </div>
301 </div>
302 );
303};
304
4
Final Steps
Update Import Paths
Make sure to update the import paths in the component code to match your project structure. For example, change @/components/ui/button
to match your UI components location.
Props
Name | Type | Default | Description |
---|---|---|---|
image | string | undefined | URL or path to the image to be scanned. |
alt | string | "Scanning image" | Alternative text for the image. |
scanDirection | "horizontal" | "vertical" | "horizontal" | Direction of the scanning effect. |
scanSpeed | number | 2 | Speed of the scanning animation in seconds. |
scanColor | "emerald" | "blue" | "purple" | "amber" | "red" | "emerald" | Color of the scanning effect. |
scanType | "line" | "corners" | "both" | "both" | Type of scanning visual effect to display. |
className | string | undefined | Additional CSS classes to apply to the container. |
onScanComplete | () => void | undefined | Callback function triggered when scanning is complete. |
autoScan | boolean | false | Automatically start scanning when component mounts. |
scanDelay | number | 0 | Delay in seconds before starting the auto scan. |
scanAtScroll | boolean | false | Trigger scan when component is scrolled into view. |
repeating | boolean | false | Enable repeated scanning in a loop. |
triggerScan | boolean | false | Externally trigger the scanning effect. |
Examples
Repeating

Scan on scroll
