Subframe
Animation & motionKaraoke caption highlight

Say it with rhythm

A warm highlight follows a two-line phrase along a continuous, deliberately paced timeline.

Play or pause the phrase, or scrub to an exact time.

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 { useEffect, useRef, useState } from "react"
const WORDS = ["Less", "noise.", "More", "feeling."]
const BEATS = [0, 0.72, 1.65, 2.3]
const DURATION = 3.8
export default function CaptionBeats({ active = true }: { active?: boolean }) {
  const [time, setTime] = useState(0),
    [playing, setPlaying] = useState(false),
    [reduced, setReduced] = useState(true),
    [seeking, setSeeking] = useState(false)
  const elapsed = useRef(0),
    phrase = useRef<HTMLParagraphElement>(null),
    highlight = useRef<HTMLSpanElement>(null)
  const word = Math.max(
    0,
    BEATS.reduce((current, value, index) => (value <= time ? index : current), 0),
  )
  useEffect(() => {
    const q = matchMedia("(prefers-reduced-motion: reduce)"),
      change = () => setReduced(q.matches)
    change()
    q.addEventListener("change", change)
    return () => q.removeEventListener("change", change)
  }, [])
  useEffect(() => {
    if (!active || !playing || reduced) return
    let frame = 0,
      last = 0
    const tick = (now: number) => {
      if (last) elapsed.current = Math.min(DURATION, elapsed.current + Math.min(now - last, 100) / 1000)
      last = now
      setTime(elapsed.current)
      if (elapsed.current >= DURATION) setPlaying(false)
      else frame = requestAnimationFrame(tick)
    }
    frame = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(frame)
  }, [active, playing, reduced])
  useEffect(() => {
    if (reduced) setPlaying(false)
  }, [reduced])
  useEffect(() => {
    const p = phrase.current,
      h = highlight.current
    if (!p || !h) return
    const update = () => {
      const el = p.querySelector<HTMLElement>(`[data-word="${word}"]`)
      if (!el) return
      h.style.transform = `translate(${el.offsetLeft - 7}px,${el.offsetTop - 3}px)`
      h.style.width = el.offsetWidth + 14 + "px"
      h.style.height = el.offsetHeight + 6 + "px"
    }
    update()
    const ro = new ResizeObserver(update)
    ro.observe(p)
    return () => ro.disconnect()
  }, [word])
  const seek = (value: number) => {
    elapsed.current = value
    setTime(value)
    setPlaying(false)
    setSeeking(true)
  }
  return (
    <section className="sf-caption-beats" data-still={reduced || !active || seeking}>
      <p className="sf-cb-caption" ref={phrase} aria-label="Less noise. More feeling.">
        <span ref={highlight} className="sf-cb-highlight" aria-hidden="true" />
        {[0, 1].map((row) => (
          <span className="sf-cb-row" key={row}>
            {WORDS.slice(row * 2, row * 2 + 2).map((w, j) => {
              const i = row * 2 + j
              return (
                <span key={w} className="sf-cb-word" data-word={i} data-current={word === i} aria-hidden="true">
                  {w}
                </span>
              )
            })}
          </span>
        ))}
      </p>
      <div className="sf-cb-transport">
        <button
          type="button"
          disabled={!active}
          aria-label={playing ? "Pause caption" : reduced ? "Next caption word" : "Play caption"}
          onClick={() => {
            setSeeking(false)
            if (reduced) {
              seek(BEATS[(word + 1) % 4])
              return
            }
            if (!playing && time >= DURATION) {
              elapsed.current = 0
              setTime(0)
            }
            setPlaying((v) => !v)
          }}
        >
          <svg viewBox="0 0 16 16" aria-hidden="true">
            {playing ? <path d="M4 3h3v10H4zM9 3h3v10H9z" /> : <path d="m5 3 8 5-8 5z" />}
          </svg>
        </button>
        <div className="sf-cb-track">
          <span aria-hidden="true" style={{ transform: `scaleX(${time / DURATION})` }} />
          <input
            type="range"
            min="0"
            max={DURATION}
            step=".01"
            aria-label="Caption time"
            aria-valuetext={time.toFixed(2) + " seconds"}
            disabled={!active}
            value={time}
            onChange={(e) => seek(+e.target.value)}
          />
          <div className="sf-cb-marks" aria-hidden="true">
            {BEATS.slice(1).map((t) => (
              <i key={t} style={{ left: (t / DURATION) * 100 + "%" }} />
            ))}
          </div>
        </div>
        <output aria-label="Caption time in seconds" aria-live="off">
          {time.toFixed(1)}
          <span> / {DURATION.toFixed(1)}</span>
        </output>
      </div>
      <style>{`.sf-caption-beats{width:100%;height:100%;position:relative;background:#1c2735;color:#faf1d9;font-family:Arial,sans-serif;overflow:hidden;display:flex;flex-direction:column;justify-content:center;align-items:center}.sf-caption-beats *{box-sizing:border-box}.sf-cb-caption{position:relative;width:440px;display:block;margin:0 0 36px;font:600 49px/1.3 Arial,sans-serif;letter-spacing:-2.2px}.sf-cb-row{display:flex;justify-content:center;gap:19px;margin:10px 0}.sf-cb-word{position:relative;z-index:1;display:inline-block;color:#7f8b96;transition:color 230ms ease}.sf-cb-word[data-current=true]{color:#1c2735}.sf-cb-highlight{position:absolute;top:0;left:0;background:#e8b45d;border-radius:8px;transition:transform 350ms cubic-bezier(.22,1,.36,1),width 300ms cubic-bezier(.22,1,.36,1),height 300ms;pointer-events:none}.sf-cb-transport{position:absolute;bottom:37px;display:flex;align-items:center;gap:19px;width:426px;color:#d7dce0}.sf-cb-transport button{width:44px;height:44px;flex-shrink:0;display:grid;place-items:center;border:1px solid #56616e;border-radius:50%;background:#ffffff05;color:inherit;cursor:pointer;touch-action:manipulation}.sf-cb-transport button:active{background:#ffffff15}.sf-cb-transport button svg{width:16px;height:16px;fill:currentColor}.sf-cb-track{position:relative;height:44px;flex:1;display:flex;align-items:center}.sf-cb-track:before{content:"";height:2px;position:absolute;left:0;right:0;background:#4b5663}.sf-cb-track>span{height:2px;position:absolute;left:0;right:0;transform-origin:left center;background:#e8b45d}.sf-cb-track input{position:absolute;inset:0;width:100%;height:44px;margin:0;background:transparent;appearance:none;cursor:ew-resize;z-index:2}.sf-cb-track input::-webkit-slider-thumb{appearance:none;width:10px;height:10px;background:#edc27e;border:2px solid #1c2735;border-radius:50%;box-shadow:0 0 0 1px #edc27e}.sf-cb-track input::-moz-range-thumb{width:8px;height:8px;background:#edc27e;border:2px solid #1c2735;border-radius:50%}.sf-cb-marks{position:absolute;left:0;right:0;pointer-events:none}.sf-cb-marks i{position:absolute;width:1px;height:6px;top:7px;background:#718092}.sf-cb-transport output{font:10px ui-monospace,monospace;font-variant-numeric:tabular-nums;width:61px;flex-shrink:0}.sf-cb-transport output>span{color:#84929f}.sf-caption-beats :focus-visible{outline:2px solid #e8b45d;outline-offset:4px}.sf-caption-beats :disabled{cursor:default;opacity:.5}.sf-caption-beats[data-still=true] *{transition:none!important}@media(prefers-reduced-motion:reduce){.sf-caption-beats *{transition:none!important}}`}</style>
    </section>
  )
}

Where it works well

Preview timed caption emphasis, lyric pacing or spoken-text motion before connecting media.

Reduced motion

No automatic playback; the play control advances one word per activation. Scrubbing still selects exact times without moving the highlight.

Things to know

A local 3.8-second timing study with authored word boundaries; no audio, transcription or alignment service.

More examples