{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An UnlockFaceId component for SmoothUI.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useEffect, useId, useRef, useState } from \"react\";\n\nexport type UnlockStatus = \"idle\" | \"scanning\" | \"success\" | \"error\";\n\nexport type UnlockFaceIdProps = {\n  /** Content gated behind the scan — blurred while locked, sharp once unlocked */\n  children?: ReactNode;\n  /** Additional CSS classes */\n  className?: string;\n  /** Message shown when the status is `error` */\n  errorMessage?: string;\n  /** Accessible label for the scan trigger */\n  label?: string;\n  /** Fired when an uncontrolled scan resolves successfully */\n  onComplete?: () => void;\n  /** Fired whenever the trigger is activated */\n  onScan?: () => void;\n  /** How long an uncontrolled scan takes, in ms */\n  scanDuration?: number;\n  /** Rendered size of the glyph, in px */\n  size?: number;\n  /** Controlled status — omit for uncontrolled behaviour */\n  status?: UnlockStatus;\n};\n\nconst DEFAULT_SIZE = 128;\nconst DEFAULT_SCAN_DURATION = 1800;\nconst DEFAULT_ERROR_MESSAGE = \"Face not recognized. Try again.\";\n\n/* -------------------------------------------------------------------------- */\n/* Motion tokens                                                              */\n/* -------------------------------------------------------------------------- */\n\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst EASE_IN_OUT: [number, number, number, number] = [0.645, 0.045, 0.355, 1];\nconst INSTANT = { duration: 0 };\nconst CHECK_SPRING = { bounce: 0.28, duration: 0.4, type: \"spring\" as const };\nconst GLYPH_SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\nconst BRACKET_DRAW = 0.4;\nconst BRACKET_STAGGER = 0.07;\nconst FACE_DELAY = 0.26;\nconst FACE_DRAW = 0.42;\nconst FACE_STAGGER = 0.07;\nconst SCAN_SWEEP = 1.5;\nconst SHAKE_DURATION = 0.4;\nconst CONTENT_REVEAL = 0.4;\nconst SHAKE_KEYFRAMES = [0, -7, 7, -5, 5, -2, 0];\n\n/* -------------------------------------------------------------------------- */\n/* Geometry — Apple's Face ID mark: four corner brackets + a schematic face    */\n/* -------------------------------------------------------------------------- */\n\nconst VIEWBOX = 100;\nconst STROKE_WIDTH = 4;\nconst CHECK_STROKE_WIDTH = 5;\n\n/** Four corner brackets only — no full outline — with a clear gap between them. */\nconst BRACKET_PATHS = [\n  \"M8 34 L8 28 A20 20 0 0 1 28 8 L34 8\",\n  \"M66 8 L72 8 A20 20 0 0 1 92 28 L92 34\",\n  \"M92 66 L92 72 A20 20 0 0 1 72 92 L66 92\",\n  \"M34 92 L28 92 A20 20 0 0 1 8 72 L8 66\",\n];\n\n/** Two short vertical eye strokes, a nose that turns right at the bottom, and a\n *  shallow smile with small upward ticks at each end. */\nconst FACE_PATHS = [\n  \"M36 38 L36 47\",\n  \"M64 38 L64 47\",\n  \"M50 38 L50 54 Q50 57.5 53.5 57.5 L56 57.5\",\n  \"M36 61 L36 66 Q50 73 64 66 L64 61\",\n];\n\nconst CHECK_PATH = \"M30 52 L44 66 L72 34\";\n\nconst SCAN_CLIP = { height: 84, rx: 22, width: 84, x: 8, y: 8 };\nconst SCAN_BAND_HEIGHT = 18;\nconst SCAN_TRAVEL = 34;\n\nconst STATUS_MESSAGES: Record<Exclude<UnlockStatus, \"error\">, string> = {\n  idle: \"Ready to scan.\",\n  scanning: \"Scanning face…\",\n  success: \"Identity confirmed. Unlocked.\",\n};\n\nconst STROKE_BY_STATUS: Record<UnlockStatus, string> = {\n  error: \"var(--color-destructive, #ef4444)\",\n  idle: \"currentColor\",\n  scanning: \"currentColor\",\n  success: \"var(--color-green, #22c55e)\",\n};\n\n/* -------------------------------------------------------------------------- */\n/* Glyph                                                                      */\n/* -------------------------------------------------------------------------- */\n\ntype FaceGlyphProps = {\n  canAnimate: boolean;\n  clipId: string;\n  gradientId: string;\n  isScanning: boolean;\n  stroke: string;\n};\n\nconst FaceGlyph = ({\n  canAnimate,\n  clipId,\n  gradientId,\n  isScanning,\n  stroke,\n}: FaceGlyphProps) => (\n  <motion.g\n    animate={{ opacity: 1, scale: 1 }}\n    exit={\n      canAnimate\n        ? { opacity: 0, scale: 0.82 }\n        : { opacity: 0, transition: INSTANT }\n    }\n    initial={\n      canAnimate ? { opacity: 0, scale: 0.94 } : { opacity: 1, scale: 1 }\n    }\n    style={{ transformOrigin: \"50px 50px\" }}\n    transition={canAnimate ? GLYPH_SPRING : INSTANT}\n  >\n    {BRACKET_PATHS.map((path, index) => (\n      <motion.path\n        animate={{ pathLength: 1 }}\n        d={path}\n        fill=\"none\"\n        initial={canAnimate ? { pathLength: 0 } : { pathLength: 1 }}\n        key={path}\n        stroke={stroke}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth={STROKE_WIDTH}\n        transition={\n          canAnimate\n            ? {\n                delay: index * BRACKET_STAGGER,\n                duration: BRACKET_DRAW,\n                ease: EASE_OUT,\n              }\n            : INSTANT\n        }\n      />\n    ))}\n\n    {FACE_PATHS.map((path, index) => (\n      <motion.path\n        animate={{ pathLength: 1 }}\n        d={path}\n        fill=\"none\"\n        initial={canAnimate ? { pathLength: 0 } : { pathLength: 1 }}\n        key={path}\n        stroke={stroke}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        strokeWidth={STROKE_WIDTH}\n        transition={\n          canAnimate\n            ? {\n                delay: FACE_DELAY + index * FACE_STAGGER,\n                duration: FACE_DRAW,\n                ease: EASE_OUT,\n              }\n            : INSTANT\n        }\n      />\n    ))}\n\n    {isScanning && canAnimate ? (\n      <g clipPath={`url(#${clipId})`}>\n        <motion.g\n          animate={{ y: SCAN_TRAVEL }}\n          initial={{ y: -SCAN_TRAVEL }}\n          transition={{\n            duration: SCAN_SWEEP,\n            ease: EASE_IN_OUT,\n            repeat: Number.POSITIVE_INFINITY,\n            repeatType: \"reverse\",\n          }}\n        >\n          <rect\n            fill={`url(#${gradientId})`}\n            height={SCAN_BAND_HEIGHT}\n            width={SCAN_CLIP.width}\n            x={SCAN_CLIP.x}\n            y={50 - SCAN_BAND_HEIGHT / 2}\n          />\n          <line\n            opacity={0.9}\n            stroke=\"var(--color-brand, #6366f1)\"\n            strokeLinecap=\"round\"\n            strokeWidth={1.5}\n            x1={SCAN_CLIP.x + 2}\n            x2={SCAN_CLIP.x + SCAN_CLIP.width - 2}\n            y1={50 + SCAN_BAND_HEIGHT / 2}\n            y2={50 + SCAN_BAND_HEIGHT / 2}\n          />\n        </motion.g>\n      </g>\n    ) : null}\n  </motion.g>\n);\n\ntype CheckGlyphProps = {\n  canAnimate: boolean;\n};\n\nconst CheckGlyph = ({ canAnimate }: CheckGlyphProps) => (\n  <motion.g\n    animate={{ opacity: 1, scale: 1 }}\n    initial={canAnimate ? { opacity: 0, scale: 0.6 } : { opacity: 1, scale: 1 }}\n    style={{ transformOrigin: \"50px 50px\" }}\n    transition={canAnimate ? CHECK_SPRING : INSTANT}\n  >\n    <motion.path\n      animate={{ pathLength: 1 }}\n      d={CHECK_PATH}\n      fill=\"none\"\n      initial={canAnimate ? { pathLength: 0 } : { pathLength: 1 }}\n      stroke=\"var(--color-green, #22c55e)\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={CHECK_STROKE_WIDTH}\n      transition={canAnimate ? CHECK_SPRING : INSTANT}\n    />\n  </motion.g>\n);\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                      */\n/* -------------------------------------------------------------------------- */\n\nconst UnlockFaceId = ({\n  children,\n  className,\n  errorMessage = DEFAULT_ERROR_MESSAGE,\n  label = \"Scan to unlock\",\n  onComplete,\n  onScan,\n  scanDuration = DEFAULT_SCAN_DURATION,\n  size = DEFAULT_SIZE,\n  status: controlledStatus,\n}: UnlockFaceIdProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const canAnimate = !shouldReduceMotion;\n  const isControlled = controlledStatus !== undefined;\n  const [internalStatus, setInternalStatus] = useState<UnlockStatus>(\"idle\");\n  const status = isControlled ? controlledStatus : internalStatus;\n  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const reactId = useId();\n  const uid = reactId.replace(/:/g, \"\");\n  const statusMessageId = `unlock-face-id-status-${uid}`;\n  const clipId = `unlock-face-id-clip-${uid}`;\n  const gradientId = `unlock-face-id-gradient-${uid}`;\n\n  useEffect(\n    () => () => {\n      if (timeoutRef.current) {\n        clearTimeout(timeoutRef.current);\n      }\n    },\n    []\n  );\n\n  const beginScan = useCallback(() => {\n    onScan?.();\n    if (isControlled) {\n      return;\n    }\n    setInternalStatus(\"scanning\");\n    if (timeoutRef.current) {\n      clearTimeout(timeoutRef.current);\n    }\n    timeoutRef.current = setTimeout(() => {\n      setInternalStatus(\"success\");\n      onComplete?.();\n    }, scanDuration);\n  }, [isControlled, onComplete, onScan, scanDuration]);\n\n  const handleTriggerClick = useCallback(() => {\n    if (status === \"error\") {\n      onScan?.();\n      if (!isControlled) {\n        setInternalStatus(\"idle\");\n      }\n      return;\n    }\n    if (status === \"idle\") {\n      beginScan();\n    }\n  }, [beginScan, isControlled, onScan, status]);\n\n  const isBusy = status === \"scanning\";\n  const isUnlocked = status === \"success\";\n  const statusMessage =\n    status === \"error\" ? errorMessage : STATUS_MESSAGES[status];\n\n  return (\n    <div className={cn(\"flex w-full flex-col items-center gap-4\", className)}>\n      <button\n        aria-busy={isBusy}\n        aria-describedby={statusMessageId}\n        aria-label={label}\n        className=\"relative flex shrink-0 cursor-pointer items-center justify-center rounded-3xl text-foreground outline-none transition-transform duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background active:scale-95 disabled:cursor-default disabled:active:scale-100 motion-reduce:transition-none motion-reduce:active:scale-100\"\n        disabled={isBusy || isUnlocked}\n        onClick={handleTriggerClick}\n        style={{ height: size, width: size }}\n        type=\"button\"\n      >\n        <motion.svg\n          animate={{\n            x: status === \"error\" && canAnimate ? SHAKE_KEYFRAMES : 0,\n          }}\n          aria-hidden=\"true\"\n          className=\"h-full w-full overflow-visible\"\n          transition={\n            canAnimate\n              ? { duration: SHAKE_DURATION, ease: EASE_IN_OUT }\n              : INSTANT\n          }\n          viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}\n        >\n          <defs>\n            <clipPath id={clipId}>\n              <rect\n                height={SCAN_CLIP.height}\n                rx={SCAN_CLIP.rx}\n                width={SCAN_CLIP.width}\n                x={SCAN_CLIP.x}\n                y={SCAN_CLIP.y}\n              />\n            </clipPath>\n            <linearGradient id={gradientId} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n              <stop\n                offset=\"0%\"\n                stopColor=\"var(--color-brand, #6366f1)\"\n                stopOpacity={0}\n              />\n              <stop\n                offset=\"100%\"\n                stopColor=\"var(--color-brand, #6366f1)\"\n                stopOpacity={0.45}\n              />\n            </linearGradient>\n          </defs>\n\n          <AnimatePresence mode=\"wait\">\n            {isUnlocked ? (\n              <CheckGlyph canAnimate={canAnimate} key=\"check\" />\n            ) : (\n              <FaceGlyph\n                canAnimate={canAnimate}\n                clipId={clipId}\n                gradientId={gradientId}\n                isScanning={isBusy}\n                key=\"face\"\n                stroke={STROKE_BY_STATUS[status]}\n              />\n            )}\n          </AnimatePresence>\n        </motion.svg>\n      </button>\n\n      <p\n        aria-live=\"polite\"\n        className={cn(\n          \"text-center font-medium text-sm transition-colors duration-200\",\n          status === \"error\" && \"text-destructive\",\n          status === \"success\" && \"text-green\",\n          (status === \"idle\" || status === \"scanning\") &&\n            \"text-muted-foreground\"\n        )}\n        id={statusMessageId}\n        role=\"status\"\n      >\n        {statusMessage}\n      </p>\n\n      {children ? (\n        <motion.div\n          animate={{\n            filter: isUnlocked ? \"blur(0px)\" : \"blur(9px)\",\n            opacity: isUnlocked ? 1 : 0.5,\n          }}\n          className={cn(\"w-full\", isUnlocked ? \"\" : \"pointer-events-none\")}\n          initial={false}\n          transition={\n            canAnimate ? { duration: CONTENT_REVEAL, ease: EASE_OUT } : INSTANT\n          }\n        >\n          {children}\n        </motion.div>\n      ) : null}\n    </div>\n  );\n};\n\nexport default UnlockFaceId;\n","path":"index.tsx","target":"components/smoothui/unlock-face-id/index.tsx","type":"registry:ui"}],"name":"unlock-face-id","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Unlock Face Id","type":"registry:ui"}