{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"privacy-mask","type":"registry:component","title":"Privacy mask","description":"A frosted optical unmasker for sensitive keys, salaries and tokens, with auto-hide timeout and one-tap copy.","author":"Hairline UI","dependencies":[],"files":[{"path":"registry/privacy-mask.tsx","type":"registry:component","content":"// Hairline UI · privacy-mask · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * PRIVACY MASK: sensitive value unmasker with liquid blur\n *\n *   masked     frosted 5px optical blur or token dots keep\n *              values confidential from screenshares and bystanders\n *   reveal     tap, click, hover, or hold anywhere on the surface\n *              to dissolve the blur over 200ms; eye icon cross-fades\n *   copy       one-tap copy with a self-drawing SVG checkmark\n *   safety     auto re-masks after a configurable timeout, or\n *              immediately on Escape or window blur\n *   responsive fully fluid width with text truncation so long\n *              keys never overflow narrow phone screens\n * ───────────────────────────────────────────────────────── */\n\nexport interface PrivacyMaskProps extends React.HTMLAttributes<HTMLDivElement> {\n  value: string;\n  /** Accessible label, e.g. \"Stripe Secret Key\" or \"Account balance\". */\n  label?: string;\n  /** How unmasking is triggered: \"click\" toggles on tap/click, \"hover\" reveals while hovering, \"hold\" requires continuous press. */\n  trigger?: \"click\" | \"hover\" | \"hold\";\n  /** Mask appearance: \"blur\" blurs actual text; \"dots\" replaces with bullets; \"truncate\" reveals prefix + suffix. */\n  maskStyle?: \"blur\" | \"dots\" | \"truncate\";\n  /** Milliseconds before auto re-masking after click reveal (0 disables auto re-mask). Defaults to 6000ms. */\n  autoHideMs?: number;\n  /** Whether to show an integrated copy button. */\n  copyable?: boolean;\n  className?: string;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction EyeIcon({ className = \"size-3.5\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path\n        d=\"M1.5 8s2.5-4.5 6.5-4.5 6.5 4.5 6.5 4.5-2.5 4.5-6.5 4.5-6.5-4.5-6.5-4.5Z\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n      <circle cx=\"8\" cy=\"8\" r=\"2\" stroke=\"currentColor\" strokeWidth=\"1.4\" />\n    </svg>\n  );\n}\n\nfunction EyeOffIcon({ className = \"size-3.5\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path\n        d=\"m2 2 12 12M6.5 6.7A2 2 0 0 0 9.3 9.5M4.2 4.4C2.8 5.4 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1.6 0 3-.7 4.1-1.7M8 3.5c4 0 6.5 4.5 6.5 4.5s-.8 1.5-2.2 2.7\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.4\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction CopyIcon({ className = \"size-3.5\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <rect x=\"5.5\" y=\"5.5\" width=\"7.5\" height=\"7.5\" rx=\"1.5\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n      <path d=\"M3.5 10.5h-1a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v1\" stroke=\"currentColor\" strokeWidth=\"1.3\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon({ drawn, reduced, className = \"size-3.5\" }: { drawn: boolean; reduced: boolean; className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className={className}>\n      <path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={{\n          strokeDashoffset: drawn ? 0 : 1,\n          transition: reduced ? \"none\" : `stroke-dashoffset ${drawn ? 320 : 180}ms ${EASE}`,\n        }}\n      />\n    </svg>\n  );\n}\n\nexport function PrivacyMask({\n  value,\n  label = \"Confidential value\",\n  trigger = \"click\",\n  maskStyle = \"blur\",\n  autoHideMs = 6000,\n  copyable = true,\n  className = \"\",\n  ...rest\n}: PrivacyMaskProps) {\n  const reduced = useReducedMotion();\n  const [revealed, setRevealed] = React.useState(false);\n  const [copied, setCopied] = React.useState(false);\n  const autoHideTimer = React.useRef<number | null>(null);\n  const copyTimer = React.useRef<number | null>(null);\n  const containerRef = React.useRef<HTMLDivElement>(null);\n\n  const clearTimers = React.useCallback(() => {\n    if (autoHideTimer.current) {\n      window.clearTimeout(autoHideTimer.current);\n      autoHideTimer.current = null;\n    }\n    if (copyTimer.current) {\n      window.clearTimeout(copyTimer.current);\n      copyTimer.current = null;\n    }\n  }, []);\n\n  const scheduleAutoHide = React.useCallback(() => {\n    if (autoHideMs <= 0) return;\n    if (autoHideTimer.current) window.clearTimeout(autoHideTimer.current);\n    autoHideTimer.current = window.setTimeout(() => {\n      setRevealed(false);\n    }, autoHideMs);\n  }, [autoHideMs]);\n\n  // Clean up timers on unmount\n  React.useEffect(() => {\n    return () => clearTimers();\n  }, [clearTimers]);\n\n  // Keyboard shortcut: Escape re-masks immediately\n  const handleKeyDown = (e: React.KeyboardEvent) => {\n    if (e.key === \"Escape\" && revealed) {\n      e.preventDefault();\n      setRevealed(false);\n    } else if ((e.key === \"Enter\" || e.key === \" \") && trigger === \"click\") {\n      e.preventDefault();\n      toggleReveal();\n    }\n  };\n\n  const toggleReveal = () => {\n    setRevealed((prev) => {\n      const next = !prev;\n      if (next) scheduleAutoHide();\n      return next;\n    });\n  };\n\n  // Pointer & Touch handlers for hold & hover\n  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (trigger === \"hold\") {\n      try {\n        e.currentTarget.setPointerCapture(e.pointerId);\n      } catch {\n        // Fallback if pointer capture unsupported\n      }\n      setRevealed(true);\n    }\n  };\n\n  const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (trigger === \"hold\") {\n      try {\n        if (e.currentTarget.hasPointerCapture(e.pointerId)) {\n          e.currentTarget.releasePointerCapture(e.pointerId);\n        }\n      } catch {\n        // Safe release\n      }\n      setRevealed(false);\n    }\n  };\n\n  const handlePointerCancel = () => {\n    if (trigger === \"hold\") {\n      setRevealed(false);\n    }\n  };\n\n  const handleCopy = async (e: React.MouseEvent) => {\n    e.stopPropagation();\n    try {\n      await navigator.clipboard.writeText(value);\n      setCopied(true);\n      if (copyTimer.current) window.clearTimeout(copyTimer.current);\n      copyTimer.current = window.setTimeout(() => setCopied(false), 1800);\n    } catch {\n      // Fallback\n    }\n  };\n\n  // Masked display formatting\n  const displayMasked = React.useMemo(() => {\n    if (maskStyle === \"truncate\" && value.length > 8) {\n      return `${value.slice(0, 4)}••••••••${value.slice(-4)}`;\n    }\n    if (maskStyle === \"dots\") {\n      return \"••••••••••••\";\n    }\n    return value;\n  }, [maskStyle, value]);\n\n  const isBlurred = !revealed && maskStyle === \"blur\";\n\n  return (\n    <div\n      ref={containerRef}\n      role=\"group\"\n      aria-label={label}\n      tabIndex={0}\n      onKeyDown={handleKeyDown}\n      onClick={trigger === \"click\" ? toggleReveal : undefined}\n      onMouseEnter={() => {\n        if (trigger === \"hover\") setRevealed(true);\n      }}\n      onMouseLeave={() => {\n        if (trigger === \"hover\") setRevealed(false);\n      }}\n      onPointerDown={handlePointerDown}\n      onPointerUp={handlePointerUp}\n      onPointerCancel={handlePointerCancel}\n      className={`group relative inline-flex w-full max-w-full items-center justify-between gap-2 overflow-hidden rounded-lg border border-border bg-card px-2.5 py-1.5 text-card-foreground select-none transition-[border-color,box-shadow,background-color] duration-200 touch-manipulation focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 ${\n        trigger === \"click\" ? \"cursor-pointer hover:bg-muted/30 active:bg-muted/50\" : trigger === \"hold\" ? \"cursor-pointer\" : \"\"\n      } ${className}`}\n      style={{\n        clipPath: \"inset(0 round 0.5rem)\",\n      }}\n      {...rest}\n    >\n      {/* Content value */}\n      <div className=\"relative min-w-0 flex-1 overflow-hidden pr-1\">\n        <span\n          className={`block truncate font-mono text-[12.5px] tabular-nums transition-[filter,opacity] duration-200 ${\n            isBlurred ? \"filter blur-[5px] opacity-60 group-hover:opacity-80\" : \"opacity-100 filter-none\"\n          }`}\n          style={{\n            transitionTimingFunction: EASE,\n          }}\n        >\n          {revealed || maskStyle === \"blur\" ? value : displayMasked}\n        </span>\n      </div>\n\n      {/* Action Buttons */}\n      <div\n        className=\"flex shrink-0 items-center gap-1 border-l border-border/60 pl-1.5 text-muted-foreground\"\n        onClick={(e) => {\n          // If clicking container toggles, let eye click toggle without double trigger\n          if (trigger === \"click\") {\n            e.stopPropagation();\n            toggleReveal();\n          }\n        }}\n      >\n        {/* Toggle Eye Button */}\n        <button\n          type=\"button\"\n          tabIndex={-1}\n          aria-label={revealed ? \"Hide value\" : \"Reveal value\"}\n          className=\"flex size-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground active:scale-95 cursor-pointer touch-manipulation\"\n        >\n          {revealed ? <EyeOffIcon /> : <EyeIcon />}\n        </button>\n\n        {/* Copy Button */}\n        {copyable && (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label={copied ? \"Copied\" : \"Copy to clipboard\"}\n            onClick={handleCopy}\n            className={`flex size-7 items-center justify-center rounded-md transition-colors hover:bg-muted active:scale-95 cursor-pointer touch-manipulation ${\n              copied ? \"text-emerald-600 dark:text-emerald-400\" : \"hover:text-foreground\"\n            }`}\n          >\n            <CheckIcon drawn={copied} reduced={reduced} className=\"size-3.5\" />\n            {!copied && <CopyIcon />}\n          </button>\n        )}\n      </div>\n\n      {/* Screen reader notification */}\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {label}: {revealed ? value : \"Hidden\"}\n      </span>\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/privacy-mask. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}