{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "voice-capture",
  "type": "registry:ui",
  "title": "Voice capture",
  "description": "Coordinates recording, levels, pause, and completion inside one input surface.",
  "dependencies": [
    "motion"
  ],
  "categories": [
    "forms-input"
  ],
  "docs": "https://motion-lexicon.pages.dev/en/components/voice-capture/",
  "meta": {
    "engines": [
      "motion"
    ],
    "runtimeCost": "light",
    "signature": "Voice capture flow with responsive levels",
    "sceneFamily": "product-mono",
    "motionRole": "ui",
    "primaryState": "Voice input showing the current level",
    "assetProvenance": "self-contained"
  },
  "files": [
    {
      "path": "src/registry/components/voice-capture.tsx",
      "type": "registry:ui",
      "target": "components/motion-lexicon/voice-capture.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\n\nconst SPRING = { type: \"spring\", stiffness: 430, damping: 32, mass: 0.58 } as const;\nconst INSTANT = { duration: 0 } as const;\n\nexport type VoiceCaptureState = \"idle\" | \"recording\" | \"paused\";\n\nexport type VoiceCaptureProps = {\n  levels?: readonly number[];\n  label?: string;\n  recordLabel?: string;\n  pauseLabel?: string;\n  resumeLabel?: string;\n  recordingLabel?: string;\n  pausedLabel?: string;\n  sendLabel?: string;\n  deleteLabel?: string;\n  onStateChange?: (state: VoiceCaptureState) => void;\n  onSend?: (durationSeconds: number) => void;\n  onDelete?: () => void;\n  className?: string;\n};\n\nconst Mic = () => <svg viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" aria-hidden><rect x=\"8\" y=\"3\" width=\"8\" height=\"12\" rx=\"4\" stroke=\"currentColor\" strokeWidth=\"1.7\"/><path d=\"M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3\" stroke=\"currentColor\" strokeWidth=\"1.7\" strokeLinecap=\"round\"/></svg>;\nconst Pause = () => <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"currentColor\" aria-hidden><rect x=\"6\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/><rect x=\"14\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/></svg>;\nconst Play = () => <svg viewBox=\"0 0 24 24\" width=\"14\" height=\"14\" fill=\"currentColor\" aria-hidden><path d=\"M8 5.8v12.4a1 1 0 0 0 1.55.84l9-6.2a1 1 0 0 0 0-1.68l-9-6.2A1 1 0 0 0 8 5.8Z\"/></svg>;\n\nexport function VoiceCapture({\n  levels = [0.22, 0.48, 0.3, 0.72, 0.45, 0.9, 0.36, 0.62, 0.28, 0.78, 0.52, 0.34, 0.84, 0.42, 0.66, 0.3, 0.56, 0.24],\n  label = \"Voice message\",\n  recordLabel = \"Record voice message\",\n  pauseLabel = \"Pause recording\",\n  resumeLabel = \"Resume recording\",\n  recordingLabel = \"Recording\",\n  pausedLabel = \"Recording paused\",\n  sendLabel = \"Send recording\",\n  deleteLabel = \"Delete recording\",\n  onStateChange,\n  onSend,\n  onDelete,\n  className = \"\",\n}: VoiceCaptureProps) {\n  const [state, setState] = useState<VoiceCaptureState>(\"idle\");\n  const [seconds, setSeconds] = useState(0);\n  const startedAt = useRef<number | null>(null);\n  const recordedMs = useRef(0);\n  const root = useRef<HTMLDivElement>(null);\n  const recordButton = useRef<HTMLButtonElement>(null);\n  const primaryControl = useRef<HTMLButtonElement>(null);\n  const pendingFocus = useRef<\"record\" | \"primary\" | null>(null);\n  const [visible, setVisible] = useState(true);\n  const reduced = useReducedMotion() === true;\n\n  useLayoutEffect(() => {\n    if (pendingFocus.current === \"primary\" && state !== \"idle\") {\n      pendingFocus.current = null;\n      primaryControl.current?.focus({ preventScroll: true });\n    } else if (pendingFocus.current === \"record\" && state === \"idle\") {\n      pendingFocus.current = null;\n      recordButton.current?.focus({ preventScroll: true });\n    }\n  }, [state]);\n\n  useEffect(() => {\n    const node = root.current;\n    if (!node || typeof IntersectionObserver === \"undefined\") return;\n    let intersecting = true;\n    let documentVisible = !document.hidden;\n    const update = () => setVisible(intersecting && documentVisible);\n    const observer = new IntersectionObserver(([entry]) => {\n      intersecting = entry.isIntersecting;\n      update();\n    });\n    const onVisibility = () => {\n      documentVisible = !document.hidden;\n      update();\n    };\n    observer.observe(node);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n    return () => {\n      observer.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n    };\n  }, [state]);\n\n  useEffect(() => {\n    if (state !== \"recording\") return;\n    const update = () => {\n      const runningMs = startedAt.current === null ? 0 : Math.max(0, Date.now() - startedAt.current);\n      setSeconds(Math.floor((recordedMs.current + runningMs) / 1000));\n    };\n    update();\n    const timer = window.setInterval(update, 1000);\n    return () => window.clearInterval(timer);\n  }, [state]);\n\n  const setPhase = (next: VoiceCaptureState) => {\n    setState(next);\n    onStateChange?.(next);\n  };\n\n  const resetDuration = () => {\n    startedAt.current = null;\n    recordedMs.current = 0;\n    setSeconds(0);\n  };\n\n  const start = () => {\n    recordedMs.current = 0;\n    startedAt.current = Date.now();\n    setSeconds(0);\n    pendingFocus.current = \"primary\";\n    setPhase(\"recording\");\n  };\n\n  const pause = () => {\n    if (startedAt.current !== null) {\n      recordedMs.current += Math.max(0, Date.now() - startedAt.current);\n      startedAt.current = null;\n    }\n    setSeconds(Math.floor(recordedMs.current / 1000));\n    setPhase(\"paused\");\n  };\n\n  const resume = () => {\n    startedAt.current = Date.now();\n    setPhase(\"recording\");\n  };\n\n  const durationSeconds = () => {\n    const runningMs = startedAt.current === null ? 0 : Math.max(0, Date.now() - startedAt.current);\n    return Math.floor((recordedMs.current + runningMs) / 1000);\n  };\n\n  const remove = () => {\n    pendingFocus.current = \"record\";\n    setPhase(\"idle\");\n    resetDuration();\n    onDelete?.();\n  };\n\n  const send = () => {\n    const duration = durationSeconds();\n    onSend?.(duration);\n    pendingFocus.current = \"record\";\n    setPhase(\"idle\");\n    resetDuration();\n  };\n\n  const time = useMemo(() => `${String(Math.floor(seconds / 60)).padStart(2, \"0\")}:${String(seconds % 60).padStart(2, \"0\")}`, [seconds]);\n  const stateLabel = state === \"recording\" ? recordingLabel : pausedLabel;\n  const transition = reduced ? INSTANT : SPRING;\n\n  if (state === \"idle\") {\n    return (\n      <button\n        ref={recordButton}\n        type=\"button\"\n        aria-label={recordLabel}\n        onClick={start}\n        className={`inline-flex min-h-11 items-center gap-2 rounded-lg border border-neutral-200 bg-white px-4 text-[13px] font-medium text-neutral-700 outline-none transition-[border-color,box-shadow,background-color] duration-150 focus-visible:border-[#4568FF] focus-visible:shadow-[0_0_0_3px_rgba(69,104,255,.2)] dark:border-white/15 dark:bg-[#202020] dark:text-neutral-200 ${className}`}\n      >\n        <span className=\"grid size-7 place-items-center rounded-md bg-neutral-100 text-neutral-700 dark:bg-white/10 dark:text-neutral-200\"><Mic /></span>\n        {label}\n      </button>\n    );\n  }\n\n  return (\n    <motion.div\n      ref={root}\n      layout\n      transition={transition}\n      className={`flex min-h-14 w-full min-w-0 max-w-[420px] items-center gap-1 rounded-[10px] border border-neutral-200 bg-white p-2 dark:border-white/15 dark:bg-[#202020] ${className}`}\n    >\n      <button\n        ref={primaryControl}\n        type=\"button\"\n        aria-label={state === \"recording\" ? pauseLabel : resumeLabel}\n        onClick={state === \"recording\" ? pause : resume}\n        className=\"grid size-11 shrink-0 place-items-center rounded-lg bg-neutral-900 text-white outline-none focus-visible:shadow-[0_0_0_3px_rgba(69,104,255,.28)] dark:bg-neutral-100 dark:text-neutral-950\"\n      >\n        {state === \"recording\" ? <Pause /> : <Play />}\n      </button>\n\n      <div className=\"flex min-w-0 flex-1 items-center gap-1\">\n        <span aria-hidden className={`size-1.5 shrink-0 rounded-full ${state === \"recording\" ? \"bg-red-600\" : \"bg-neutral-300 dark:bg-neutral-600\"}`} />\n        <div aria-hidden className=\"flex h-8 min-w-0 flex-1 items-center justify-center gap-0 overflow-hidden min-[390px]:gap-0.5\">\n          {levels.map((level, index) => {\n            const base = Math.max(0.14, Math.min(1, level));\n            return (\n              <motion.span\n                key={index}\n                className=\"h-6 min-w-0 max-w-[3px] flex-1 origin-center rounded-full bg-neutral-400 dark:bg-neutral-500\"\n                animate={state === \"recording\" && !reduced && visible\n                  ? { transform: [`scaleY(${base})`, `scaleY(${Math.min(1, base * 1.55)})`, `scaleY(${base})`] }\n                  : { transform: `scaleY(${state === \"paused\" ? base * 0.58 : base})` }}\n                transition={state === \"recording\" && !reduced && visible\n                  ? { duration: 0.68 + (index % 4) * 0.08, repeat: Infinity, ease: \"easeInOut\", delay: index * 0.018 }\n                  : transition}\n              />\n            );\n          })}\n        </div>\n        <span role=\"timer\" aria-label={`${stateLabel}, ${time}`} className=\"w-9 shrink-0 text-right font-mono text-[11px] tabular-nums text-neutral-600 dark:text-neutral-300\">{time}</span>\n      </div>\n\n      <button\n        type=\"button\"\n        aria-label={deleteLabel}\n        onClick={remove}\n        className=\"grid size-11 shrink-0 place-items-center rounded-lg text-[18px] text-neutral-500 outline-none transition-colors duration-150 hover:bg-neutral-100 hover:text-neutral-700 focus-visible:shadow-[0_0_0_3px_rgba(69,104,255,.2)] dark:text-neutral-400 dark:hover:bg-white/10 dark:hover:text-neutral-200\"\n      >\n        <span aria-hidden>×</span>\n      </button>\n      <button\n        type=\"button\"\n        aria-label={sendLabel}\n        onClick={send}\n        className=\"grid size-11 shrink-0 place-items-center rounded-lg bg-neutral-950 text-white outline-none transition-[background-color,box-shadow] duration-150 hover:bg-neutral-800 focus-visible:shadow-[0_0_0_3px_rgba(69,104,255,.25)] dark:bg-neutral-50 dark:text-neutral-950\"\n      >\n        <span aria-hidden>↗</span>\n      </button>\n      <AnimatePresence><motion.span key={state} role=\"status\" className=\"sr-only\">{stateLabel}</motion.span></AnimatePresence>\n    </motion.div>\n  );\n}\n"
    }
  ]
}
