Github

Pattern Lock

A customizable UI component for drawing patterns by connecting dots for authentication or creative input.

Pattern Lock
Draw a pattern by connecting the dots
Loading...
Output: ""

Documentation

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.

Features

  • Click and drag to connect the dots and create a pattern.
  • Press the Reset button to clear the pattern.
  • The output below the lock shows the pattern as a sequence of numbers.
  • Fully responsive and works in both light and dark themes.

Usage

usage.tsx
1import { PatternLock } from "@/components/pattern-lock"; 2 3<PatternLock 4 pattern={pattern} 5 onPatternChange={setPattern} 6 dotSize={24} 7 lineWidth={4} 8/>;

Props

PropTypeDefaultDescription
patternnumber[][]Current pattern as array of dot indices
onPatternChange(pattern: number[]) => void-Callback when pattern changes
onPatternComplete(pattern: number[]) => void-Callback when drawing is complete
dotSizenumber20Size of the dots in pixels
lineWidthnumber4Width of the connecting lines
classNamestring-Additional CSS classes
disabledbooleanfalseWhether the component is disabled

Installation

terminal
1npm install next-themes lucide-react

Component

pattern-lock.tsx
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}