A customizable UI component for drawing patterns by connecting dots for authentication or creative input.
Pattern Lock is a UI component that allows users to draw a pattern by connecting dots, similar to the pattern lock feature found on many mobile devices.
1import { PatternLock } from "@/components/pattern-lock";
2
3<PatternLock
4 pattern={pattern}
5 onPatternChange={setPattern}
6 dotSize={24}
7 lineWidth={4}
8/>;
Prop | Type | Default | Description |
---|---|---|---|
pattern | number[] | [] | Current pattern as array of dot indices |
onPatternChange | (pattern: number[]) => void | - | Callback when pattern changes |
onPatternComplete | (pattern: number[]) => void | - | Callback when drawing is complete |
dotSize | number | 20 | Size of the dots in pixels |
lineWidth | number | 4 | Width of the connecting lines |
className | string | - | Additional CSS classes |
disabled | boolean | false | Whether the component is disabled |
1npm install next-themes lucide-react
1"use client";
2
3import * as React from "react";
4import { cn } from "@/lib/utils";
5import { useTheme } from "next-themes";
6
7interface PatternLockProps {
8 pattern?: number[];
9 onPatternComplete?: (pattern: number[]) => void;
10 onPatternChange?: (pattern: number[]) => void;
11 dotSize?: number;
12 lineWidth?: number;
13 className?: string;
14 disabled?: boolean;
15}
16
17interface Point {
18 x: number;
19 y: number;
20}
21
22function isPointInDot(
23 point: Point | null,
24 dot: Point,
25 radius: number
26): boolean {
27 if (!point) return false;
28 const distance = Math.sqrt((point.x - dot.x) ** 2 + (point.y - dot.y) ** 2);
29 return distance <= radius;
30}
31
32function getPointFromEvent(
33 event: React.MouseEvent | React.TouchEvent,
34 canvasRef: React.RefObject<HTMLCanvasElement | null>
35): Point {
36 const canvas = canvasRef.current;
37 if (!canvas) return { x: 0, y: 0 };
38 const rect = canvas.getBoundingClientRect();
39 const scaleX = canvas.width / rect.width;
40 const scaleY = canvas.height / rect.height;
41 if ("touches" in event) {
42 const touch = event.touches[0] || event.changedTouches[0];
43 return {
44 x: (touch.clientX - rect.left) * scaleX,
45 y: (touch.clientY - rect.top) * scaleY,
46 };
47 } else {
48 return {
49 x: (event.clientX - rect.left) * scaleX,
50 y: (event.clientY - rect.top) * scaleY,
51 };
52 }
53}
54
55function handleKeyDown(
56 event: React.KeyboardEvent,
57 onPatternChange?: (pattern: number[]) => void
58) {
59 if (event.key === "Escape" || event.key === "Delete") {
60 onPatternChange?.([]);
61 }
62}
63
64export function PatternLock({
65 pattern = [],
66 onPatternComplete,
67 onPatternChange,
68 dotSize = 20,
69 lineWidth = 4,
70 className,
71 disabled = false,
72}: PatternLockProps) {
73 const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
74 const [isDrawing, setIsDrawing] = React.useState(false);
75 const [currentPoint, setCurrentPoint] = React.useState<Point | null>(null);
76 const [size, setSize] = React.useState(300);
77 const { resolvedTheme } = useTheme();
78 const [isHoveringDot, setIsHoveringDot] = React.useState(false);
79 const [mounted, setMounted] = React.useState(false);
80
81 const safeTheme = mounted ? resolvedTheme : "light";
82
83 React.useEffect(() => {
84 setMounted(true);
85 function updateSize() {
86 if (typeof window !== "undefined" && window.innerWidth < 640) {
87 setSize(250);
88 } else {
89 setSize(300);
90 }
91 }
92 updateSize();
93 if (typeof window !== "undefined") {
94 window.addEventListener("resize", updateSize);
95 return () => window.removeEventListener("resize", updateSize);
96 }
97 }, []);
98
99 const dots = React.useMemo(() => {
100 const dotsArray: Point[] = [];
101 const spacing = size / 4;
102 for (let row = 0; row < 3; row++) {
103 for (let col = 0; col < 3; col++) {
104 dotsArray.push({
105 x: spacing + col * spacing,
106 y: spacing + row * spacing,
107 });
108 }
109 }
110 return dotsArray;
111 }, [size]);
112
113 const drawCanvas = React.useCallback(() => {
114 if (!mounted) return;
115 const canvas = canvasRef.current;
116 if (!canvas) return;
117 const ctx = canvas.getContext("2d");
118 if (!ctx) return;
119 ctx.clearRect(0, 0, size, size);
120 if (pattern.length > 1) {
121 let lineColor = "#000";
122 if (safeTheme === "dark") {
123 lineColor = "#fff";
124 }
125 ctx.strokeStyle = lineColor;
126 ctx.lineWidth = lineWidth;
127 ctx.lineCap = "round";
128 ctx.lineJoin = "round";
129 ctx.beginPath();
130 ctx.moveTo(dots[pattern[0]].x, dots[pattern[0]].y);
131 for (let i = 1; i < pattern.length; i++) {
132 ctx.lineTo(dots[pattern[i]].x, dots[pattern[i]].y);
133 }
134 if (isDrawing && currentPoint) {
135 ctx.lineTo(currentPoint.x, currentPoint.y);
136 }
137 ctx.stroke();
138 }
139 dots.forEach((dot, index) => {
140 const isSelected = pattern.includes(index);
141 const isActive =
142 isSelected || (isDrawing && isPointInDot(currentPoint, dot, dotSize));
143 ctx.beginPath();
144 ctx.arc(dot.x, dot.y, dotSize / 2, 0, 2 * Math.PI);
145 let dotColor = "#000";
146 if (safeTheme === "dark") {
147 dotColor = "#fff";
148 }
149 if (isActive) {
150 ctx.fillStyle = "hsl(var(--primary))";
151 ctx.fill();
152 ctx.strokeStyle = "hsl(var(--primary-foreground))";
153 ctx.lineWidth = 2;
154 ctx.stroke();
155 } else {
156 ctx.fillStyle = dotColor;
157 ctx.fill();
158 ctx.strokeStyle = "hsl(var(--border))";
159 ctx.lineWidth = 2;
160 ctx.stroke();
161 }
162 });
163 }, [
164 mounted,
165 pattern,
166 isDrawing,
167 currentPoint,
168 dots,
169 size,
170 dotSize,
171 lineWidth,
172 safeTheme,
173 ]);
174
175 const handleMove = (event: React.MouseEvent | React.TouchEvent) => {
176 if (!isDrawing || disabled) return;
177 event.preventDefault();
178 const point = getPointFromEvent(event, canvasRef);
179 setCurrentPoint(point);
180 const dotIndex = dots.findIndex((dot) => isPointInDot(point, dot, dotSize));
181 if (dotIndex !== -1 && !pattern.includes(dotIndex)) {
182 const newPattern = [...pattern, dotIndex];
183 onPatternChange?.(newPattern);
184 }
185 };
186
187 const handleMouseMove = (event: React.MouseEvent | React.TouchEvent) => {
188 if (!isDrawing && !disabled && "clientX" in event) {
189 const point = getPointFromEvent(event, canvasRef);
190 const overDot = dots.some((dot) => isPointInDot(point, dot, dotSize));
191 setIsHoveringDot(overDot);
192 }
193 handleMove(event);
194 };
195
196 React.useEffect(() => {
197 drawCanvas();
198 }, [drawCanvas]);
199
200 const handleStart = (event: React.MouseEvent | React.TouchEvent) => {
201 if (disabled) return;
202 event.preventDefault();
203 const point = getPointFromEvent(event, canvasRef);
204 setIsDrawing(true);
205 setCurrentPoint(point);
206 const dotIndex = dots.findIndex((dot) => isPointInDot(point, dot, dotSize));
207 if (dotIndex !== -1) {
208 const newPattern = [dotIndex];
209 onPatternChange?.(newPattern);
210 }
211 };
212
213 const handleEnd = (event: React.MouseEvent | React.TouchEvent) => {
214 if (!isDrawing || disabled) return;
215 event.preventDefault();
216 setIsDrawing(false);
217 setCurrentPoint(null);
218 if (pattern.length > 0) {
219 onPatternComplete?.(pattern);
220 }
221 };
222
223 if (!mounted) {
224 return (
225 <div
226 className={cn("relative inline-block", className)}
227 style={{ width: size, height: size }}
228 >
229 <div
230 className="border border-border rounded-lg bg-background flex items-center justify-center"
231 style={{ width: size, height: size }}
232 >
233 <span className="text-muted-foreground">Loading...</span>
234 </div>
235 </div>
236 );
237 }
238
239 return (
240 <div
241 className={cn("relative inline-block", className)}
242 role="application"
243 aria-label="Pattern lock interface"
244 tabIndex={0}
245 onKeyDown={(e) => handleKeyDown(e, onPatternChange)}
246 >
247 <canvas
248 ref={canvasRef}
249 width={size}
250 height={size}
251 className={cn(
252 "border border-border rounded-lg bg-background touch-none",
253 disabled && "opacity-50 cursor-not-allowed",
254 isHoveringDot ? "cursor-pointer" : "cursor-default"
255 )}
256 onMouseDown={handleStart}
257 onMouseMove={handleMouseMove}
258 onMouseUp={handleEnd}
259 onMouseLeave={(e) => {
260 setIsHoveringDot(false);
261 handleEnd(e);
262 }}
263 onTouchStart={handleStart}
264 onTouchMove={handleMove}
265 onTouchEnd={handleEnd}
266 aria-label="Pattern lock grid - draw a pattern by connecting dots"
267 />
268 </div>
269 );
270}