{
  "name": "synced-transcript",
  "type": "registry:component",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Synced Transcript\n * @version: 1.0.0\n * @date: 2026-10-16\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { ArrowDown, ArrowUp, Pause, Play } from \"lucide-react\";\nimport {\n  type KeyboardEvent,\n  type MouseEvent,\n  memo,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst SPEEDS = [1, 1.5, 2];\nconst WHITESPACE = /\\s+/;\n\nexport interface TranscriptSegment {\n  speaker: string;\n  /** Seconds from the start. */\n  start: number;\n  end: number;\n  text: string;\n}\n\nexport interface SyncedTranscriptProps {\n  title?: string;\n  segments?: TranscriptSegment[];\n  /** Seconds. Defaults to the end of the last segment. Playback is simulated; drive it from your own audio with `onTimeChange`. */\n  duration?: number;\n  onTimeChange?: (seconds: number) => void;\n  className?: string;\n}\n\ninterface Word {\n  text: string;\n  start: number;\n  /** Index across the whole transcript. */\n  index: number;\n}\n\n// Times each segment from its word count, with a breath between speakers.\nfunction timed(lines: [string, string][]): TranscriptSegment[] {\n  const out: TranscriptSegment[] = [];\n  let t = 0.6;\n  for (const [speaker, text] of lines) {\n    const words = text.split(WHITESPACE).length;\n    const end = t + words / 2.7;\n    out.push({ speaker, start: t, end, text });\n    t = end + 0.7;\n  }\n  return out;\n}\n\nconst DEFAULT_SEGMENTS = timed([\n  [\n    \"Maya\",\n    \"Welcome back. Today we're talking about the small details in interfaces, the ones nobody notices until they're missing.\",\n  ],\n  [\n    \"Theo\",\n    \"Right. Like a menu that opens in a hundred and fifty milliseconds instead of four hundred. You never think about it, but the slow one feels broken.\",\n  ],\n  [\"Maya\", \"So where do you start when something feels off?\"],\n  [\n    \"Theo\",\n    \"I slow it down. I record the screen and step through it frame by frame. Most of the time the easing is wrong, or two things move that should move as one.\",\n  ],\n  [\"Maya\", \"And when it finally feels right?\"],\n  [\n    \"Theo\",\n    \"Then I stop touching it. The best motion is the kind you only miss when it's gone.\",\n  ],\n]);\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\n\nfunction formatTime(seconds: number) {\n  const s = Math.max(0, Math.floor(seconds));\n  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, \"0\")}`;\n}\n\n// Spreads a segment's time over its words by length, so long words last longer.\nfunction splitWords(segments: TranscriptSegment[]) {\n  let index = 0;\n  return segments.map((segment) => {\n    const parts = segment.text.split(WHITESPACE).filter(Boolean);\n    const total = parts.reduce((sum, w) => sum + w.length + 1, 0);\n    let t = segment.start;\n    return parts.map((text) => {\n      const word = { text, start: t, index };\n      t += ((text.length + 1) / total) * (segment.end - segment.start);\n      index += 1;\n      return word;\n    });\n  });\n}\n\n// What each paragraph needs to know: all spoken, none yet, or which word.\n// Past and future paragraphs get a constant, so they skip re-rendering.\nfunction paragraphActive(segment: number, current: number, active: number) {\n  if (segment < current) {\n    return Number.MAX_SAFE_INTEGER;\n  }\n  return segment === current ? active : -1;\n}\n\n// Only the paragraph holding the spoken word re-renders as time moves.\nconst Paragraph = memo(function Paragraph({\n  words,\n  active,\n}: {\n  words: Word[];\n  /** The spoken word's global index. */\n  active: number;\n}) {\n  return (\n    <>\n      {words.map((word) => (\n        <span\n          className={cn(\n            \"transition-colors duration-150 ease-out\",\n            word.index <= active\n              ? \"text-zinc-900 dark:text-zinc-100\"\n              : \"text-zinc-400 dark:text-zinc-500\"\n          )}\n          data-index={word.index}\n          key={word.index}\n        >\n          {word.text}{\" \"}\n        </span>\n      ))}\n    </>\n  );\n});\n\nexport default function SyncedTranscript({\n  title = \"Frame Rate · Episode 48\",\n  segments = DEFAULT_SEGMENTS,\n  duration: durationProp,\n  onTimeChange,\n  className,\n}: SyncedTranscriptProps) {\n  const duration = durationProp ?? Math.ceil((segments.at(-1)?.end ?? 0) + 0.6);\n  const words = useMemo(() => splitWords(segments), [segments]);\n  const flat = useMemo(() => words.flat(), [words]);\n\n  const [time, setTime] = useState(0);\n  const [playing, setPlaying] = useState(false);\n  const [speedIndex, setSpeedIndex] = useState(0);\n  const [following, setFollowing] = useState(true);\n  // Where the spoken word is when the reader has scrolled away from it.\n  const [away, setAway] = useState<\"up\" | \"down\" | null>(null);\n  const timeRef = useRef(0);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const markRef = useRef<HTMLDivElement>(null);\n  const lastTop = useRef<number | null>(null);\n  const rate = SPEEDS[speedIndex];\n\n  useEffect(() => {\n    if (!playing) {\n      return;\n    }\n    let frame = 0;\n    let last = performance.now();\n    const tick = (now: number) => {\n      const next = Math.min(\n        duration,\n        timeRef.current + ((now - last) / 1000) * rate\n      );\n      last = now;\n      timeRef.current = next;\n      setTime(next);\n      if (next >= duration) {\n        setPlaying(false);\n        return;\n      }\n      frame = requestAnimationFrame(tick);\n    };\n    frame = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frame);\n  }, [playing, duration, rate]);\n\n  useEffect(() => {\n    onTimeChange?.(time);\n  }, [time, onTimeChange]);\n\n  const seek = useCallback(\n    (seconds: number) => {\n      const next = clamp(seconds, 0, duration);\n      timeRef.current = next;\n      setTime(next);\n    },\n    [duration]\n  );\n\n  // The last word that has started, or -1 before the first.\n  let active = -1;\n  for (const word of flat) {\n    if (word.start <= time) {\n      active = word.index;\n    } else {\n      break;\n    }\n  }\n  const activeSegment = words.findIndex((ws) =>\n    ws.some((w) => w.index === active)\n  );\n\n  const scrollToWord = useCallback((el: HTMLElement, smooth: boolean) => {\n    const box = scrollRef.current;\n    if (!box) {\n      return;\n    }\n    // Keep the spoken line a third of the way down, like lyrics.\n    const top =\n      box.scrollTop +\n      el.getBoundingClientRect().top -\n      box.getBoundingClientRect().top -\n      box.clientHeight / 3;\n    const reduce = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    box.scrollTo({ top, behavior: smooth && !reduce ? \"smooth\" : \"auto\" });\n  }, []);\n\n  // One highlight slides from word to word; it jumps, unanimated, to a new line.\n  useLayoutEffect(() => {\n    const mark = markRef.current;\n    const el = contentRef.current?.querySelector<HTMLElement>(\n      `[data-index=\"${active}\"]`\n    );\n    if (!(mark && el)) {\n      if (mark) {\n        mark.style.opacity = \"0\";\n      }\n      lastTop.current = null;\n      return;\n    }\n    const sameLine = lastTop.current === el.offsetTop;\n    mark.style.transition = sameLine\n      ? \"transform 140ms cubic-bezier(0.23, 1, 0.32, 1), width 140ms cubic-bezier(0.23, 1, 0.32, 1), opacity 150ms ease-out\"\n      : \"opacity 150ms ease-out\";\n    // The trailing space is part of the span; leave it out of the mark.\n    const space = Number.parseFloat(getComputedStyle(el).fontSize) * 0.28;\n    mark.style.transform = `translate(${el.offsetLeft - 3}px, ${el.offsetTop}px)`;\n    mark.style.width = `${el.offsetWidth - space + 6}px`;\n    mark.style.height = `${el.offsetHeight}px`;\n    mark.style.opacity = \"1\";\n    if (!sameLine && following) {\n      scrollToWord(el, lastTop.current !== null);\n    }\n    lastTop.current = el.offsetTop;\n  }, [active, following, scrollToWord]);\n\n  // When the reader scrolls away, note which way the spoken word went.\n  const checkAway = useCallback(() => {\n    const box = scrollRef.current;\n    const el = contentRef.current?.querySelector<HTMLElement>(\n      `[data-index=\"${active}\"]`\n    );\n    if (!(box && el) || following) {\n      setAway(null);\n      return;\n    }\n    const word = el.getBoundingClientRect();\n    const view = box.getBoundingClientRect();\n    if (word.bottom < view.top) {\n      setAway(\"up\");\n    } else if (word.top > view.bottom) {\n      setAway(\"down\");\n    } else {\n      setAway(null);\n    }\n  }, [active, following]);\n\n  useEffect(() => {\n    checkAway();\n  }, [checkAway]);\n\n  const takeOver = () => setFollowing(false);\n\n  const follow = () => {\n    setFollowing(true);\n    setAway(null);\n    const el = contentRef.current?.querySelector<HTMLElement>(\n      `[data-index=\"${active}\"]`\n    );\n    if (el) {\n      scrollToWord(el, true);\n    }\n  };\n\n  const handleWordClick =\n    (segment: TranscriptSegment) => (event: MouseEvent<HTMLButtonElement>) => {\n      const target = (event.target as HTMLElement).closest(\"[data-index]\");\n      const index = Number(target?.getAttribute(\"data-index\") ?? -1);\n      // Keyboard presses have no word under them; start the paragraph.\n      seek(index >= 0 ? (flat[index]?.start ?? segment.start) : segment.start);\n      setFollowing(true);\n    };\n\n  const handleProgress = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.type === \"pointerdown\") {\n      event.currentTarget.setPointerCapture(event.pointerId);\n    } else if (!event.currentTarget.hasPointerCapture(event.pointerId)) {\n      return;\n    }\n    const rect = event.currentTarget.getBoundingClientRect();\n    seek(((event.clientX - rect.left) / rect.width) * duration);\n    setFollowing(true);\n  };\n\n  const handleProgressKey = (event: KeyboardEvent<HTMLDivElement>) => {\n    const moves: Record<string, number> = {\n      ArrowLeft: -5,\n      ArrowDown: -5,\n      ArrowRight: 5,\n      ArrowUp: 5,\n      Home: -duration,\n      End: duration,\n    };\n    if (event.key === \" \") {\n      event.preventDefault();\n      setPlaying((p) => !p);\n      return;\n    }\n    const move = moves[event.key];\n    if (move !== undefined) {\n      event.preventDefault();\n      seek(time + move);\n      setFollowing(true);\n    }\n  };\n\n  const togglePlay = () => {\n    if (!playing && timeRef.current >= duration) {\n      seek(0);\n    }\n    setPlaying((p) => !p);\n  };\n\n  return (\n    <div\n      className={cn(\n        \"w-full max-w-md select-none rounded-3xl border border-zinc-200 bg-white text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-100\",\n        className\n      )}\n    >\n      <div className=\"flex items-center justify-between gap-3 px-5 pt-5\">\n        <div className=\"min-w-0\">\n          <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">Transcript</p>\n          <p className=\"truncate font-medium text-sm\">{title}</p>\n        </div>\n        <button\n          aria-label={`Playback speed ${rate}x`}\n          className=\"h-7 shrink-0 rounded-full border border-zinc-200 px-2.5 font-medium text-xs tabular-nums outline-none transition-colors duration-150 ease-out hover:bg-zinc-100 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:border-zinc-800 dark:hover:bg-zinc-900\"\n          onClick={() => setSpeedIndex((i) => (i + 1) % SPEEDS.length)}\n          type=\"button\"\n        >\n          {rate}×\n        </button>\n      </div>\n\n      <div className=\"relative\">\n        <section\n          aria-label=\"Transcript\"\n          className=\"h-72 overflow-y-auto px-5 py-6 outline-none [mask-image:linear-gradient(to_bottom,transparent,black_24px,black_calc(100%-24px),transparent)] focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-inset\"\n          onScroll={checkAway}\n          onTouchMove={takeOver}\n          onWheel={takeOver}\n          ref={scrollRef}\n        >\n          <div className=\"relative isolate\" ref={contentRef}>\n            <div\n              aria-hidden\n              className=\"pointer-events-none absolute top-0 left-0 -z-10 rounded-md bg-zinc-100 opacity-0 dark:bg-zinc-800/80\"\n              ref={markRef}\n            />\n            {segments.map((segment, i) => (\n              <div\n                className={cn(i > 0 && \"mt-5\")}\n                key={`${segment.start}-${segment.speaker}`}\n              >\n                <div className=\"mb-1 flex items-center gap-2 text-xs\">\n                  <span\n                    aria-hidden\n                    className=\"flex size-5 items-center justify-center rounded-full bg-zinc-100 font-medium text-[10px] text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300\"\n                  >\n                    {segment.speaker.charAt(0)}\n                  </span>\n                  <span className=\"font-medium\">{segment.speaker}</span>\n                  <button\n                    aria-label={`Play from ${formatTime(segment.start)}`}\n                    className=\"rounded text-zinc-400 tabular-nums outline-none transition-colors duration-150 ease-out hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-zinc-400 dark:text-zinc-500 dark:hover:text-zinc-100\"\n                    onClick={() => {\n                      seek(segment.start);\n                      setFollowing(true);\n                    }}\n                    type=\"button\"\n                  >\n                    {formatTime(segment.start)}\n                  </button>\n                </div>\n                <button\n                  className=\"block cursor-pointer rounded-sm text-left text-[15px] leading-7 outline-none focus-visible:ring-2 focus-visible:ring-zinc-400\"\n                  onClick={handleWordClick(segment)}\n                  tabIndex={-1}\n                  type=\"button\"\n                >\n                  <Paragraph\n                    active={paragraphActive(i, activeSegment, active)}\n                    words={words[i]}\n                  />\n                </button>\n              </div>\n            ))}\n          </div>\n        </section>\n\n        {/* Back to the spoken word after scrolling away. */}\n        <button\n          className={cn(\n            \"absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full bg-zinc-900 px-3 py-1.5 font-medium text-white text-xs shadow-sm outline-none transition-[opacity,translate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] focus-visible:ring-2 focus-visible:ring-zinc-400 dark:bg-zinc-100 dark:text-zinc-900\",\n            away\n              ? \"translate-y-0 opacity-100\"\n              : \"pointer-events-none translate-y-2 opacity-0\"\n          )}\n          onClick={follow}\n          tabIndex={away ? 0 : -1}\n          type=\"button\"\n        >\n          {away === \"up\" ? (\n            <ArrowUp aria-hidden className=\"size-3.5\" />\n          ) : (\n            <ArrowDown aria-hidden className=\"size-3.5\" />\n          )}\n          Back to now\n        </button>\n      </div>\n\n      <div className=\"flex items-center gap-3 border-zinc-200 border-t px-5 py-3 dark:border-zinc-800\">\n        <button\n          aria-label={playing ? \"Pause\" : \"Play\"}\n          className=\"flex size-9 shrink-0 items-center justify-center rounded-full bg-zinc-900 text-white outline-none transition-colors duration-150 ease-out hover:bg-zinc-700 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2 dark:bg-zinc-100 dark:text-zinc-900 dark:focus-visible:ring-offset-zinc-950 dark:hover:bg-zinc-300\"\n          onClick={togglePlay}\n          type=\"button\"\n        >\n          {playing ? (\n            <Pause aria-hidden className=\"size-4\" fill=\"currentColor\" />\n          ) : (\n            <Play\n              aria-hidden\n              className=\"size-4 translate-x-px\"\n              fill=\"currentColor\"\n            />\n          )}\n        </button>\n        <span className=\"w-9 text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n          {formatTime(time)}\n        </span>\n        <div\n          aria-label=\"Seek\"\n          aria-valuemax={Math.round(duration)}\n          aria-valuemin={0}\n          aria-valuenow={Math.round(time)}\n          aria-valuetext={`${formatTime(time)} of ${formatTime(duration)}`}\n          className=\"group relative flex h-6 flex-1 cursor-pointer touch-none items-center rounded outline-none focus-visible:ring-2 focus-visible:ring-zinc-400\"\n          onKeyDown={handleProgressKey}\n          onPointerDown={handleProgress}\n          onPointerMove={handleProgress}\n          role=\"slider\"\n          tabIndex={0}\n        >\n          <div className=\"h-1 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-800\">\n            <div\n              className=\"h-full w-full origin-left rounded-full bg-zinc-900 dark:bg-zinc-100\"\n              style={{ transform: `scaleX(${time / duration})` }}\n            />\n          </div>\n        </div>\n        <span className=\"w-9 text-right text-xs text-zinc-500 tabular-nums dark:text-zinc-400\">\n          {formatTime(duration)}\n        </span>\n      </div>\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/synced-transcript.tsx",
      "target": "components/kokonutui/synced-transcript.tsx"
    }
  ]
}