{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["controls","geometric"],"dependencies":["lucide-react","motion"],"description":"Android's 9-dot pattern lock — glowing trail, dot ripples, red shake on a wrong draw, green on unlock.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { LockOpen } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion, useAnimate, useReducedMotion } from \"motion/react\";\n\nconst GRID = 3;\nconst CELL = 92;\nconst PAD = 46;\nconst BOARD = CELL * (GRID - 1) + PAD * 2;\nconst HIT_RADIUS = 30;\nconst CORRECT = [0, 3, 6, 7, 8];\n\ntype Phase = \"idle\" | \"drawing\" | \"error\" | \"success\";\n\nconst dotPosition = (index: number) => ({\n  x: PAD + (index % GRID) * CELL,\n  y: PAD + Math.floor(index / GRID) * CELL,\n});\n\nconst STROKE: Record<Phase, string> = {\n  drawing: \"#38bdf8\",\n  error: \"#ef4444\",\n  idle: \"#38bdf8\",\n  success: \"#34d399\",\n};\n\nexport const PatternUnlock = () => {\n  const [path, setPath] = useState<number[]>([]);\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);\n  const [scope, animate] = useAnimate();\n  const boardRef = useRef<HTMLDivElement | null>(null);\n  const timersRef = useRef<ReturnType<typeof setTimeout>[]>([]);\n  const reduceMotion = useReducedMotion() ?? false;\n\n  useEffect(() => {\n    const timers = timersRef.current;\n    return () => {\n      for (const timer of timers) {\n        clearTimeout(timer);\n      }\n    };\n  }, []);\n\n  const queue = useCallback((fn: () => void, ms: number) => {\n    timersRef.current.push(setTimeout(fn, ms));\n  }, []);\n\n  const localPoint = (clientX: number, clientY: number) => {\n    const rect = boardRef.current?.getBoundingClientRect();\n    if (!rect) {\n      return null;\n    }\n    // Rect is in screen pixels; dots are in board pixels. They differ when an ancestor is scaled.\n    const scale = rect.width / BOARD;\n    return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };\n  };\n\n  const capture = useCallback((point: { x: number; y: number }) => {\n    setPath((previous) => {\n      for (let index = 0; index < GRID * GRID; index += 1) {\n        if (previous.includes(index)) {\n          continue;\n        }\n        const dot = dotPosition(index);\n        if (Math.hypot(dot.x - point.x, dot.y - point.y) < HIT_RADIUS) {\n          return [...previous, index];\n        }\n      }\n      return previous;\n    });\n  }, []);\n\n  const finish = useCallback(() => {\n    setCursor(null);\n    setPath((current) => {\n      if (current.length === 0) {\n        setPhase(\"idle\");\n        return current;\n      }\n      if (current.join(\",\") === CORRECT.join(\",\")) {\n        setPhase(\"success\");\n        queue(() => {\n          setPhase(\"idle\");\n          setPath([]);\n        }, 2200);\n      } else {\n        setPhase(\"error\");\n        if (scope.current && !reduceMotion) {\n          void animate(\n            scope.current,\n            { x: [0, -12, 12, -8, 8, 0] },\n            { duration: 0.4, ease: \"easeInOut\" },\n          );\n        }\n        queue(() => {\n          setPhase(\"idle\");\n          setPath([]);\n        }, 800);\n      }\n      return current;\n    });\n  }, [animate, queue, reduceMotion, scope]);\n\n  const points = path.map(dotPosition);\n  const stroke = STROKE[phase];\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"flex w-[340px] flex-col items-center rounded-[36px] bg-[#08110b] py-9 shadow-2xl shadow-black/60 ring-1 ring-emerald-200/10 select-none\">\n        <AnimatePresence mode=\"wait\" initial={false}>\n          {phase === \"success\" ? (\n            <motion.div\n              key=\"unlocked\"\n              initial={{ opacity: 0, scale: 0.8 }}\n              animate={{ opacity: 1, scale: 1 }}\n              exit={{ opacity: 0 }}\n              transition={{ bounce: 0.5, duration: 0.4, type: \"spring\" }}\n              className=\"flex items-center gap-2 text-emerald-400\"\n            >\n              <LockOpen className=\"size-4\" />\n              <span className=\"text-[14px] font-semibold\">Unlocked</span>\n            </motion.div>\n          ) : (\n            <motion.p\n              key=\"prompt\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              exit={{ opacity: 0 }}\n              className={`text-[14px] font-medium ${phase === \"error\" ? \"text-red-400\" : \"text-white/70\"}`}\n            >\n              {phase === \"error\" ? \"Try again\" : \"Draw your pattern\"}\n            </motion.p>\n          )}\n        </AnimatePresence>\n\n        <div ref={scope} className=\"mt-4\">\n          <div\n            ref={boardRef}\n            style={{ height: BOARD, width: BOARD }}\n            className=\"relative cursor-pointer touch-none\"\n            onPointerDown={(event) => {\n              event.currentTarget.setPointerCapture(event.pointerId);\n              setPath([]);\n              setPhase(\"drawing\");\n              const point = localPoint(event.clientX, event.clientY);\n              if (point) {\n                capture(point);\n                setCursor(point);\n              }\n            }}\n            onPointerMove={(event) => {\n              if (phase !== \"drawing\") {\n                return;\n              }\n              const point = localPoint(event.clientX, event.clientY);\n              if (point) {\n                capture(point);\n                setCursor(point);\n              }\n            }}\n            onPointerUp={finish}\n            onPointerCancel={finish}\n          >\n            <svg width={BOARD} height={BOARD} className=\"absolute inset-0\">\n              {points.length > 0 && (\n                <polyline\n                  points={[...points, ...(phase === \"drawing\" && cursor ? [cursor] : [])]\n                    .map((p) => `${p.x},${p.y}`)\n                    .join(\" \")}\n                  fill=\"none\"\n                  stroke={stroke}\n                  strokeWidth=\"4\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  opacity={0.85}\n                  style={{ filter: `drop-shadow(0 0 8px ${stroke})` }}\n                />\n              )}\n            </svg>\n\n            {Array.from({ length: GRID * GRID }, (_, index) => {\n              const dot = dotPosition(index);\n              const active = path.includes(index);\n              return (\n                <motion.span\n                  key={index}\n                  className=\"absolute grid size-5 -translate-x-1/2 -translate-y-1/2 place-items-center\"\n                  style={{ left: dot.x, top: dot.y }}\n                >\n                  <motion.span\n                    animate={{\n                      backgroundColor: active ? stroke : \"rgba(200,255,220,0.38)\",\n                      scale: active ? 1.5 : 1,\n                    }}\n                    transition={{ bounce: 0.5, duration: 0.3, type: \"spring\" }}\n                    className=\"size-3 rounded-full\"\n                  />\n                  {active && (\n                    <motion.span\n                      aria-hidden\n                      initial={{ opacity: 0.6, scale: 0.4 }}\n                      animate={{ opacity: 0, scale: 2.4 }}\n                      transition={{ duration: 0.45, ease: \"easeOut\" }}\n                      className=\"absolute size-5 rounded-full\"\n                      style={{ backgroundColor: stroke }}\n                    />\n                  )}\n                </motion.span>\n              );\n            })}\n          </div>\n        </div>\n\n        <p className=\"mt-2 text-[11px] text-white/30\">The pattern is an L — start top-left</p>\n      </div>\n    </MotionConfig>\n  );\n};\n","path":"/pattern-unlock.tsx","target":"uicapsule/pattern-unlock/pattern-unlock.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { PatternUnlock } from \"./pattern-unlock\";\n\nconst Preview = () => (\n  <main className=\"flex h-dvh items-center justify-center overflow-hidden bg-[repeating-linear-gradient(0deg,rgb(134_239_172/0.025)_0_1px,transparent_1px_4px),radial-gradient(circle_at_50%_45%,rgb(34_197_94/0.13)_0%,transparent_45%),linear-gradient(180deg,#07130c_0%,#040b07_60%,#020503_100%)] p-5\">\n    <PatternUnlock />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/pattern-unlock/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/pattern-unlock","name":"pattern-unlock","registryDependencies":[],"title":"Pattern Unlock","type":"registry:block"}