Subframe
Components & interactionsSynced transcript player

Cue sheet

Timecoded phrases and a compact waveform share a playhead that follows every selected word.

Play the silent timing preview, select a word or sentence timestamp, or scrub the waveform

Subframe Original · MIT

Add to your project

Component source

Standalone React component. Styles are included.

React only. No additional packages.

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * MIT License
 * Copyright (c) 2026 Subframe
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */
"use client"

import * as React from "react"

const LINES = [
  "We kept the windows open.",
  "Let the city become the score.",
  "A train. A voice. Then silence.",
  "That was the whole idea.",
]
const WORDS = LINES.flatMap((line, row) => line.split(" ").map((word) => ({ word, row })))
const STARTS = WORDS.reduce<number[]>(
  (times, entry, i) => [
    ...times,
    i ? times[i - 1] + 0.22 + WORDS[i - 1].word.length * 0.035 + (/[.,]/.test(WORDS[i - 1].word) ? 0.33 : 0) : 0,
  ],
  [],
)
const DURATION = STARTS[STARTS.length - 1] + 0.8
const ROW_STARTS = LINES.map((_, row) => STARTS[WORDS.findIndex((word) => word.row === row)])
const format = (time: number) => `00:${Math.floor(time).toString().padStart(2, "0")}`

export default function Example({ active = true }: { active?: boolean }) {
  const [time, setTime] = React.useState(2.7)
  const [playing, setPlaying] = React.useState(false)
  const [hidden, setHidden] = React.useState(false)
  const timeRef = React.useRef(time)
  React.useEffect(() => {
    const update = () => setHidden(document.hidden)
    update()
    document.addEventListener("visibilitychange", update)
    return () => document.removeEventListener("visibilitychange", update)
  }, [])
  React.useEffect(() => {
    if (!playing || !active || hidden) return
    let last = performance.now(),
      frame = 0
    const tick = (now: number) => {
      timeRef.current = Math.min(DURATION, timeRef.current + (now - last) / 1000)
      last = now
      setTime(timeRef.current)
      if (timeRef.current >= DURATION) setPlaying(false)
      else frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [playing, active, hidden])
  function seek(next: number) {
    timeRef.current = next
    setTime(next)
  }
  const index = STARTS.reduce((last, start, i) => (start <= time ? i : last), 0)
  const currentRow = WORDS[index].row
  return (
    <section className="sf-transcript-follow">
      <div className="sheet">
        <header>
          <span>Windows open</span>
        </header>
        <div className="cues" aria-label="Timed transcript">
          {LINES.map((line, row) => (
            <div className="cue" key={line} data-current={currentRow === row}>
              <button
                className="cue-time"
                aria-label={`Seek to sentence ${row + 1}`}
                disabled={!active}
                onClick={() => seek(ROW_STARTS[row])}
              >
                {format(ROW_STARTS[row])}
              </button>
              <p>
                {WORDS.map((entry, wordIndex) =>
                  entry.row === row ? (
                    <React.Fragment key={wordIndex}>
                      <button
                        aria-label={`Seek to ${entry.word}, word ${wordIndex + 1}`}
                        aria-current={wordIndex === index ? "true" : undefined}
                        data-past={wordIndex < index}
                        disabled={!active}
                        onClick={() => seek(STARTS[wordIndex])}
                      >
                        {entry.word}
                      </button>{" "}
                    </React.Fragment>
                  ) : null,
                )}
              </p>
            </div>
          ))}
        </div>
        <div className="transport">
          <button
            className="play"
            disabled={!active}
            aria-label={playing ? "Pause transcript" : "Play transcript"}
            onClick={() => {
              if (timeRef.current >= DURATION) seek(0)
              setPlaying(!playing)
            }}
          >
            <svg viewBox="0 0 24 24" aria-hidden>
              {playing ? (
                <>
                  <path d="M8 5v14M16 5v14" stroke="currentColor" strokeWidth="3" />
                </>
              ) : (
                <path d="m9 5 10 7-10 7Z" fill="currentColor" />
              )}
            </svg>
          </button>
          <div className="rail">
            <div className="wave" aria-hidden>
              <svg viewBox="0 0 420 36" preserveAspectRatio="none">
                {Array.from({ length: 105 }, (_, i) => {
                  const height = 3 + Math.abs(Math.sin(i * 0.71) * Math.cos(i * 0.153)) * 27
                  return (
                    <rect
                      key={i}
                      x={i * 4}
                      y={(36 - height) / 2}
                      width="2"
                      height={height}
                      rx="1"
                      fill={i / 104 <= time / DURATION ? "#e8f169" : "#62685f"}
                    />
                  )
                })}
              </svg>
              <div className="playhead" style={{ left: `${(time / DURATION) * 100}%` }}>
                <i />
              </div>
            </div>
            <input
              type="range"
              aria-label="Transcript position"
              min="0"
              max={DURATION}
              step=".01"
              value={time}
              disabled={!active}
              onChange={(event) => seek(+event.target.value)}
            />
          </div>
          <output>
            {format(time)}
            <span> / {format(DURATION)}</span>
          </output>
        </div>
        <footer>
          <span>Timing preview · no audio</span>
        </footer>
      </div>
      <style>{`
.sf-transcript-follow{width:100%;height:100%;display:grid;place-items:center;overflow:hidden;container-type:inline-size;isolation:isolate;background:#d4d9d0;color:#292f2e;font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.sf-transcript-follow *{box-sizing:border-box}.sf-transcript-follow .sheet{width:min(500px,calc(100% - 48px));padding:0 23px;background:#f8f9f6;border:1px solid #bfc7bc;border-radius:7px;box-shadow:0 20px 28px -24px #2d3c3670,0 1px 2px #293d2412}.sf-transcript-follow header{height:51px;display:flex;align-items:center;justify-content:space-between;font-size:12px;font-weight:600;border-bottom:1px solid #d6dbd1}.sf-transcript-follow .cues{padding:10px 0 12px}.sf-transcript-follow .cue{display:grid;grid-template-columns:44px 1fr;align-items:center;min-height:44px;position:relative}.sf-transcript-follow button{font:inherit;touch-action:manipulation;cursor:pointer;color:inherit}.sf-transcript-follow .cue-time{font:9px ui-monospace,monospace;background:none;border:0;padding:0;text-align:left;height:44px;color:#93988e;transition:color .12s ease}.sf-transcript-follow .cue[data-current=true] .cue-time{color:#333b2f}.sf-transcript-follow p{margin:0;font:19px/1.4 Georgia,"Times New Roman",serif;letter-spacing:-.35px;white-space:nowrap}.sf-transcript-follow p button{display:inline;position:relative;border:0;border-radius:2px;background:none;padding:3px 0;color:#6c7564;transition:color .15s ease;line-height:1.4;isolation:isolate}.sf-transcript-follow p button:after{content:"";position:absolute;inset:-8px -1px;z-index:-1}.sf-transcript-follow p [data-past=true]{color:#343c32}.sf-transcript-follow p [aria-current=true]{color:#252f1e;background:#e8f169;box-shadow:3px 0 #e8f169,-3px 0 #e8f169}.sf-transcript-follow .transport{height:65px;padding:0 13px 0 10px;margin:0 -10px;display:flex;align-items:center;gap:14px;background:#2c342d;border:1px solid #253026;border-radius:6px;box-shadow:inset 0 1px #ffffff14}.sf-transcript-follow .play{height:44px;width:44px;flex:none;display:grid;place-items:center;border:1px solid #e3eb8c;border-radius:50%;background:#e8f169;color:#2d3427;transition:transform .15s ease}.sf-transcript-follow .play svg{height:20px;width:20px}.sf-transcript-follow .play:active{transform:scale(.96)}.sf-transcript-follow .rail{height:44px;position:relative;flex:1;min-width:0}.sf-transcript-follow .wave{position:absolute;inset:4px 0;pointer-events:none}.sf-transcript-follow .wave svg{display:block;width:100%;height:100%}.sf-transcript-follow .playhead{position:absolute;top:-2px;bottom:-2px;width:1px;background:#e8f169;transform:translateX(-50%)}.sf-transcript-follow .playhead i{position:absolute;top:-1px;left:-2px;width:5px;height:4px;background:#e8f169;border-radius:1px}.sf-transcript-follow input{position:absolute;inset:0;margin:0;appearance:none;-webkit-appearance:none;background:none;width:100%;height:44px;cursor:ew-resize;touch-action:pan-y}.sf-transcript-follow input::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:12px;height:42px;background:transparent;border:0}.sf-transcript-follow input::-moz-range-thumb{width:12px;height:42px;background:transparent;border:0}.sf-transcript-follow .transport output{font:10px ui-monospace,monospace;color:#f3f7e9;font-variant-numeric:tabular-nums;white-space:nowrap;min-width:32px}.sf-transcript-follow .transport output span{display:block;font-size:9px;color:#a8b09f;margin-top:4px}.sf-transcript-follow footer{height:31px;display:flex;align-items:center;justify-content:space-between;color:#7b8476;font:9px ui-monospace,monospace}.sf-transcript-follow :focus-visible{outline:2px solid #356340;outline-offset:3px}.sf-transcript-follow .transport :focus-visible{outline-color:#edf99b}.sf-transcript-follow button:disabled{cursor:default}.sf-transcript-follow input:disabled{cursor:default}@media(hover:hover){.sf-transcript-follow p button:not([aria-current=true]):hover{color:#253322;background:#ebeee3}.sf-transcript-follow .play:hover{background:#f3f99a}}@container(max-width:420px){.sf-transcript-follow .sheet{width:calc(100% - 24px);padding:0 15px}.sf-transcript-follow p{font-size:16px;letter-spacing:-.3px}.sf-transcript-follow .cue{grid-template-columns:38px 1fr}.sf-transcript-follow .transport{gap:9px}.sf-transcript-follow header{height:43px}.sf-transcript-follow .cues{padding:6px 0 8px}.sf-transcript-follow .cue{min-height:40px}.sf-transcript-follow .cue-time{height:40px}}@media(prefers-reduced-motion:reduce){.sf-transcript-follow *{transition:none!important}}
      `}</style>
    </section>
  )
}

Where it works well

Review a voiceover through its words while retaining surrounding sentence context and precise time position.

Reduced motion

Removes decorative color transitions; word/time correspondence and user-requested playback remain available without scrolling or positional text animation.

Things to know

Silent, authored timing with a decorative waveform. Integrations must provide actual alignment data and audio amplitude information; the waveform does not represent a real recording. Host pause and document hiding suspend playback.

More examples