{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"eye-toggle","type":"registry:component","title":"Eye toggle","description":"The eye shuts when your password is hidden and watches while you type.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/eye-toggle.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState, type RefObject } from \"react\";\nimport {\n  animate,\n  motion,\n  useMotionValue,\n  useSpring,\n  useTransform,\n  type AnimationPlaybackControls,\n} from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\n\n// Eye geometry in a 24 unit box. The upper lid's control points travel from\n// 5 (open) down to 19, where the lid lies on the lower one and the eye is\n// shut. Two decimals keeps the path strings stable and short.\nconst OPEN_Y = 5;\nconst SHUT_Y = 19;\nconst round = (n: number) => Math.round(n * 100) / 100;\nconst topLid = (c: number) => `M2 12C6 ${c} 18 ${c} 22 12`;\nconst LOWER_LID = \"M2 12C6 19 18 19 22 12\";\n\n// How far the iris may wander from centre before it would clip the lids.\nconst GAZE_X = 4;\nconst GAZE_Y = 2;\n// Distance (px) at which the gaze reaches its limit; nearer targets get a\n// proportionally smaller glance, so a cursor on the icon looks straight on.\nconst GAZE_REACH = 160;\n\nexport function EyeToggle({\n  visible,\n  onToggle,\n  inputRef,\n  controls,\n  className,\n}: {\n  visible: boolean;\n  /** id of the input it reveals, for aria-controls. */\n  controls?: string;\n  onToggle: () => void;\n  /** The field it reveals; while typing, the eye looks at its caret. */\n  inputRef?: RefObject<HTMLInputElement | null>;\n  className?: string;\n}) {\n  const clipId = `eye-${useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n  const reduceMotion = useReducedMotion();\n  const button = useRef<HTMLButtonElement>(null);\n\n  const lid = useMotionValue(visible ? 0 : 1);\n  const lidCurve = useTransform(lid, (v) =>\n    topLid(round(OPEN_Y + (SHUT_Y - OPEN_Y) * v)),\n  );\n  const aperture = useTransform(\n    lid,\n    (v) => `${topLid(round(OPEN_Y + (SHUT_Y - OPEN_Y) * v))}C18 19 6 19 2 12Z`,\n  );\n  // Springs so the eye darts and settles like an eye, not a cursor.\n  const gazeX = useSpring(0, { stiffness: 380, damping: 28 });\n  const gazeY = useSpring(0, { stiffness: 380, damping: 28 });\n\n  const lookAt = (x: number, y: number) => {\n    const el = button.current;\n    if (!el) return;\n    const r = el.getBoundingClientRect();\n    // Back to unscaled px, so a zoomed preview glances as far as the page.\n    const s = r.width / el.offsetWidth || 1;\n    const dx = (x - (r.left + r.width / 2)) / s;\n    const dy = (y - (r.top + r.height / 2)) / s;\n    const dist = Math.hypot(dx, dy);\n    if (dist < 1) {\n      gazeX.set(0);\n      gazeY.set(0);\n      return;\n    }\n    const reach = Math.min(1, dist / GAZE_REACH);\n    gazeX.set((dx / dist) * reach * GAZE_X);\n    gazeY.set((dy / dist) * reach * GAZE_Y);\n  };\n  const lookAtRef = useRef(lookAt);\n  useEffect(() => {\n    lookAtRef.current = lookAt;\n  });\n\n  // Opening springs up; closing is a quicker, flatter fall with no bounce.\n  useEffect(() => {\n    const controls = reduceMotion\n      ? animate(lid, visible ? 0 : 1, { duration: 0 })\n      : visible\n        ? animate(lid, 0, { type: \"spring\", duration: 0.35, bounce: 0.2 })\n        : animate(lid, 1, { duration: 0.2, ease: [0.23, 1, 0.32, 1] });\n    if (!visible) {\n      gazeX.set(0);\n      gazeY.set(0);\n    }\n    return () => controls.stop();\n  }, [visible, reduceMotion, lid, gazeX, gazeY]);\n\n  // The pupil follows the cursor while open, on fine pointers only: on\n  // touch there is no cursor to follow between taps.\n  useEffect(() => {\n    if (!visible || reduceMotion) return;\n    if (!window.matchMedia(\"(pointer: fine)\").matches) return;\n    const move = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\" || e.pointerType === \"pen\")\n        lookAtRef.current(e.clientX, e.clientY);\n    };\n    window.addEventListener(\"pointermove\", move, { passive: true });\n    return () => window.removeEventListener(\"pointermove\", move);\n  }, [visible, reduceMotion]);\n\n  // While typing, the eye reads along with the caret.\n  useEffect(() => {\n    const input = inputRef?.current;\n    if (!input || !visible || reduceMotion) return;\n    const canvas = document.createElement(\"canvas\");\n    const ctx = canvas.getContext(\"2d\");\n    const follow = () => {\n      if (!ctx) return;\n      const cs = getComputedStyle(input);\n      ctx.font = cs.font;\n      const upto = input.value.slice(0, input.selectionEnd ?? input.value.length);\n      const r = input.getBoundingClientRect();\n      // The offsets below are layout px; the rect is post-transform.\n      const s = r.width / input.offsetWidth || 1;\n      const x =\n        r.left +\n        (parseFloat(cs.paddingLeft) +\n          parseFloat(cs.borderLeftWidth) +\n          ctx.measureText(upto).width -\n          input.scrollLeft) *\n          s;\n      lookAtRef.current(Math.min(x, r.right), r.top + r.height / 2);\n    };\n    const events = [\"input\", \"keyup\", \"click\", \"select\", \"focus\"] as const;\n    events.forEach((t) => input.addEventListener(t, follow));\n    return () => events.forEach((t) => input.removeEventListener(t, follow));\n  }, [inputRef, visible, reduceMotion]);\n\n  // Blinks now and then while open and idle: 3 to 7s apart so it never\n  // settles into a rhythm you can predict.\n  useEffect(() => {\n    if (!visible || reduceMotion) return;\n    let timer: ReturnType<typeof setTimeout>;\n    let blink: AnimationPlaybackControls | undefined;\n    const schedule = () => {\n      timer = setTimeout(\n        () => {\n          if (!document.hidden)\n            // A real blink is about 150ms: a quick fall, a slightly slower\n            // lift.\n            blink = animate(lid, [0, 1, 0], {\n              duration: 0.17,\n              times: [0, 0.4, 1],\n              ease: [\"easeIn\", \"easeOut\"],\n            });\n          schedule();\n        },\n        3000 + Math.random() * 4000,\n      );\n    };\n    schedule();\n    return () => {\n      clearTimeout(timer);\n      blink?.stop();\n    };\n  }, [visible, reduceMotion, lid]);\n\n  return (\n    <button\n      ref={button}\n      type=\"button\"\n      aria-label={visible ? \"Hide password\" : \"Show password\"}\n      aria-pressed={visible}\n      aria-controls={controls}\n      onClick={onToggle}\n      // Keeps focus (and the caret) in the field.\n      onPointerDown={(e) => e.preventDefault()}\n      className={cn(\n        \"flex size-8 touch-manipulation items-center justify-center rounded-md text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\",\n        visible && \"text-foreground\",\n        className,\n      )}\n    >\n      <svg\n        viewBox=\"0 0 24 24\"\n        className=\"size-5 overflow-visible\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.6}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n        aria-hidden\n      >\n        <defs>\n          <clipPath id={clipId}>\n            <motion.path d={aperture} />\n          </clipPath>\n        </defs>\n        <g clipPath={`url(#${clipId})`}>\n          <motion.g style={{ x: gazeX, y: gazeY }}>\n            <circle cx=\"12\" cy=\"12\" r=\"3.4\" />\n            <circle cx=\"12\" cy=\"12\" r=\"1.3\" fill=\"currentColor\" stroke=\"none\" />\n          </motion.g>\n        </g>\n        <path d={LOWER_LID} />\n        <motion.path d={lidCurve} />\n        {/* Lashes hang from the shut lid; they belong to \"hidden\", not to a\n            blink, so they follow the state rather than the lid. */}\n        <motion.g\n          initial={false}\n          animate={\n            visible\n              ? { opacity: 0, y: reduceMotion ? 0 : -1.5 }\n              : { opacity: 1, y: 0 }\n          }\n          transition={\n            visible\n              ? { duration: 0.1 }\n              : { duration: 0.18, delay: reduceMotion ? 0 : 0.12, ease: [0.23, 1, 0.32, 1] }\n          }\n        >\n          <path d=\"M5.2 15.4 3.9 17.4\" />\n          <path d=\"M12 17.3V19.8\" />\n          <path d=\"M18.8 15.4 20.1 17.4\" />\n        </motion.g>\n      </svg>\n    </button>\n  );\n}\n\nexport function RevealPasswordField({\n  label = \"Password\",\n  visible: visibleProp,\n  onVisibleChange,\n  className,\n}: {\n  label?: string;\n  /** Controlled reveal state; leave unset to let the field own it. */\n  visible?: boolean;\n  onVisibleChange?: (visible: boolean) => void;\n  className?: string;\n}) {\n  const id = useId();\n  const input = useRef<HTMLInputElement>(null);\n  const [visibleState, setVisible] = useState(false);\n  const visible = visibleProp ?? visibleState;\n  return (\n    <div className={cn(\"grid w-[min(320px,100%)] gap-1.5\", className)}>\n      <label htmlFor={id} className=\"text-sm font-medium text-foreground\">\n        {label}\n      </label>\n      <div className=\"relative\">\n        <input\n          ref={input}\n          id={id}\n          type={visible ? \"text\" : \"password\"}\n          autoComplete=\"current-password\"\n          spellCheck={false}\n          className=\"h-10 w-full rounded-lg border border-border bg-background pr-11 pl-3 text-[15px] text-foreground shadow-[0_1px_2px_oklch(0_0_0/0.04)] outline-hidden transition-[border-color,box-shadow] duration-150 ease-out focus-visible:border-foreground/40 focus-visible:shadow-[0_0_0_3px_color-mix(in_oklch,var(--foreground)_10%,transparent)]\"\n        />\n        <EyeToggle\n          visible={visible}\n          onToggle={() => {\n            setVisible(!visible);\n            onVisibleChange?.(!visible);\n          }}\n          inputRef={input}\n          controls={id}\n          className=\"absolute top-1 right-1\"\n        />\n      </div>\n    </div>\n  );\n}\n\n// Index card hover: a few hidden characters, the eye opens to check them,\n// reads along as the rest are typed, then shuts again. Times are ms from the\n// start of each run.\nconst SCRIPT: [number, string | boolean][] = [\n  [300, \"o\"], [420, \"p\"], [540, \"e\"], [660, \"n\"],\n  [1100, true],\n  [1700, \"s\"], [1830, \"e\"], [1960, \"s\"], [2090, \"a\"], [2220, \"m\"], [2350, \"e\"],\n  [3200, false],\n];\nconst RUN_MS = 5400;\nconst ERASE_AT = 4000;\n// Held backspace speed.\nconst ERASE_STEP = 45;\n\nexport default function EyeToggleDemo() {\n  const play = usePreviewPlay();\n  const wrap = useRef<HTMLDivElement>(null);\n  const [shown, setShown] = useState(false);\n  const erase = useRef<ReturnType<typeof setInterval>>(undefined);\n\n  useEffect(() => {\n    if (!play) return;\n    const input = wrap.current?.querySelector(\"input\");\n    if (!input) return;\n    // Typed straight into the DOM (the field is uncontrolled); the input\n    // event is what the eye already listens to, so it reads the caret the\n    // same way it does for real typing. Nothing is focused.\n    const type = (value: string) => {\n      input.value = value;\n      input.dispatchEvent(new Event(\"input\", { bubbles: true }));\n    };\n    const eraseAll = () => {\n      clearInterval(erase.current);\n      erase.current = setInterval(() => {\n        type(input.value.slice(0, -1));\n        if (!input.value) clearInterval(erase.current);\n      }, ERASE_STEP);\n    };\n    clearInterval(erase.current);\n    type(\"\");\n    let timers: ReturnType<typeof setTimeout>[] = [];\n    const run = () => {\n      timers = SCRIPT.map(([at, step]) =>\n        setTimeout(() => {\n          if (typeof step === \"boolean\") setShown(step);\n          else type(input.value + step);\n        }, at),\n      );\n      timers.push(setTimeout(eraseAll, ERASE_AT), setTimeout(run, RUN_MS));\n    };\n    run();\n    return () => {\n      timers.forEach(clearTimeout);\n      // Unhovered: let go the way a person would, not all at once.\n      setShown(false);\n      if (input.value) eraseAll();\n    };\n  }, [play]);\n\n  useEffect(() => () => clearInterval(erase.current), []);\n\n  return (\n    <div ref={wrap} className=\"contents\">\n      <RevealPasswordField\n        visible={play === null ? undefined : shown}\n        onVisibleChange={setShown}\n      />\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"}],"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/eye-toggle"}