Free Online Toolbox for developers

Building a synced lyrics view in JavaScript: Parsing LRC, enhanced LRC and WebVTT

Scrolling, highlighted lyrics used to be a feature of big streaming apps. Today it shows up in much smaller projects: an artist’s website, a karaoke mode in a party game, a language-learning app that plays songs, a lyric video tool. The browser side is not hard, but the details are easy to get wrong: odd timestamp formats, files with Windows line endings, word-level tags, and audio that drifts against the text.

This guide walks through a small, dependency-free implementation: parse an LRC file (including the enhanced word-by-word variant), find the current line efficiently, keep the display in sync with an <audio> element, and convert the result to WebVTT when you need subtitles. At the end there is a checklist of edge cases and a practical way to get reliable test files.

The formats in two minutes

Standard LRC is plain text. Each lyric line starts with a timestamp in square brackets:

[ti:Night Drive]
[ar:Example Artist]
[offset:+200]
[00:12.40]Headlights on the empty road
[00:16.90]Radio low, the windows down
[00:21.30][01:05.30]Keep on driving, keep on driving
[00:25.80]

Lines can carry several timestamps when the same words repeat (a chorus, for example). Tags such as [ti:] and [ar:] are metadata, and [offset:] shifts every timestamp by a number of milliseconds. A line with a timestamp and no text is commonly used to clear the display during an instrumental break.

Enhanced LRC adds a timestamp in angle brackets before each word, so a player can light up words as they are sung:

[00:12.40]<00:12.40>Headlights <00:13.10>on <00:13.35>the <00:13.60>empty <00:14.20>road

WebVTT is the subtitle format browsers understand natively. Each cue has a start and end time, so it maps naturally onto lines of lyrics.

Step 1: Parse LRC into lines and words

The parser below handles multiple timestamps per line, optional fractions with one to three digits, metadata, the [offset:] tag, word tags and Windows line endings.

const TIME = '(\\d{1,3}):(\\d{2})(?:[.:](\\d{1,3}))?';
const LINE_TAG = new RegExp(`^\\[${TIME}\\]`);

const WORD_TAG = new RegExp(`<${TIME}>([^<]*)`, 'g');
const toSeconds = (min, sec, frac) =>
  Number(min) * 60 + Number(sec) + (frac ? Number(`0.${frac}`) : 0);

function parseLrc(text) {
  let offset = 0;
  const entries = [];
  for (const raw of text.replace(/^/, '').split(/\r?\n/)) {
    let rest = raw.trim();
    const off = rest.match(/^\[offset:\s*([+-]?\d+)\s*\]$/i);
    if (off) {
      offset = Number(off[1]) / 1000;
      continue;
    }
    const times = [];
    let m;
    while ((m = rest.match(LINE_TAG))) {
      times.push(toSeconds(m[1], m[2], m[3]));
      rest = rest.slice(m[0].length);
    }
    for (const time of times) entries.push({ time, raw: rest.trim() });
  }

  // A positive [offset:] makes the lyrics appear earlier.
  const shift = (t) => Math.max(0, t - offset);
  return entries
    .map(({ time, raw }) => ({
      time: shift(time),
      text: raw.replace(/<[^>]*>/g, '').trim(),
      words: [...raw.matchAll(WORD_TAG)].map((w) => ({
        time: shift(toSeconds(w[1], w[2], w[3])),
        text: w[4],
      })),
    }))
    .sort((a, b) => a.time - b.time);
}

Metadata lines such as [ar:Example Artist] never match LINE_TAG, so they are skipped. Fractions are read as decimals, so .4, .40 and .400 all mean 0.4 seconds. If words is empty, the file is plain line-timed LRC and you highlight whole lines only.

Step 2: Find the current line quickly

On every frame you need the last line whose start time is at or before the playback position. Lines are sorted, so a binary search keeps this cheap even for long tracks:

function currentIndex(lines, t) {
let lo = 0;
  let hi = lines.length - 1;
  let found = -1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (lines[mid].time <= t) {
      found = mid;
      lo = mid + 1;
    } else {
      hi = mid - 1;
    }
  }
  return found; // -1 means "before the first line"
}

The same function works for words inside the active line: pass lines[i].words instead of lines.

Step 3: Keep the view in sync with the audio

It is tempting to listen for the timeupdate event, but browsers fire it only a few times per second. That is fine for line highlighting and visibly late for word highlighting. A requestAnimationFrame loop that runs while the audio plays gives smooth results:

const audio = document.querySelector('audio');
let active = -1;

function tick() {
  const i = currentIndex(lines, audio.currentTime);
  if (i !== active) {
    active = i;
    renderLine(i); // add a class to line i and scroll it into view
  }
  if (i >= 0 && lines[i].words.length) {
    renderWord(currentIndex(lines[i].words, audio.currentTime));
  }
  if (!audio.paused) requestAnimationFrame(tick);
}

audio.addEventListener('play', () => requestAnimationFrame(tick));
audio.addEventListener('seeked', tick);

Only touch the DOM when the index changes, and use scrollIntoView({ block: ‘center’, behavior: ‘smooth’ }) on the active line rather than calculating scroll offsets by hand.

Step 4: Convert to WebVTT for subtitles

If the same lyrics need to appear on a video, convert the parsed lines into WebVTT cues. Each cue ends where the next line begins, which also means a blank timestamped line correctly ends the previous cue:

const vttTime = (t) => {
const ms = Math.round(t * 1000);
  const hh = String(Math.floor(ms / 3600000)).padStart(2, '0');
  const mm = String(Math.floor(ms / 60000) % 60).padStart(2, '0');
  const ss = String(Math.floor(ms / 1000) % 60).padStart(2, '0');
  return `${hh}:${mm}:${ss}.${String(ms % 1000).padStart(3, '0')}`;
};

function toVtt(lines, tail = 4) {
  const cues = [];
  lines.forEach((line, i) => {
    if (!line.text) return;
    const end = i + 1 < lines.length ? lines[i + 1].time : line.time + tail;
    cues.push(`${vttTime(line.time)} --> ${vttTime(end)}\n${line.text}`);
  });
  return `WEBVTT\n\n${cues.join('\n\n')}\n`;
}

Attach the result to a <video> with a <track kind=”captions”> element. On an <audio> element the browser will not draw the cues, but you can set the track’s mode to “hidden” and listen for cuechange events to drive your own lyrics view.

Edge cases worth testing

Real-world LRC files are messy. Before shipping, make sure your parser and view handle:

  • a UTF-8 byte order mark at the start of the file and \r\n line endings;
  • lines with two or more timestamps, and timestamps that are not in order;
  • [mm:ss] with no fraction, and fractions with three digits;
  • metadata tags anywhere in the file, including [offset:] after the first lyric line;
  • empty timestamped lines used as breaks;
  • lines with no timestamp at all, which should be ignored rather than shown at 0:00;
  • languages without spaces between words. In Chinese or Japanese lyrics, word tags often wrap single characters, so never assume a space separates two highlighted units.

Getting reliable test files

The hardest part of building a lyrics view is often the test data. You need audio you are allowed to use and lyrics that are timed accurately, ideally down to the word. Tapping along by hand is slow and imprecise, and lyric databases rarely include word-level timing.

An alignment tool saves a lot of time here. Lacuna’s LRC generator takes an MP3, WAV, FLAC, M4A or OGG file (up to 10 MB and 10 minutes), aligns lyrics you paste to the vocals, or transcribes the song if you have no lyrics, and gives every word its own timestamp. You can fix any line in its editor and export standard LRC, word-by-word LRC, SRT, VTT or plain text. Because every export comes from the same timing, the files make a useful reference when you compare your own parser and converter output. You can try it with free credits; downloading the files requires a paid plan.

If you do not have a suitable song, create one. You can generate a song from lyrics you write instead of borrowing a commercial track, which also means you know every word in advance. Generating the same lyrics as a fast rap and a slow ballad is a quick way to stress-test word highlighting at very different speeds.

Wrapping up

A synced lyrics view needs surprisingly little code: a tolerant parser, a binary search and an animation loop. Most of the work is in handling messy input and checking the timing against real audio. Build those tests early, and the feature will hold up when users start loading files from every player and tool they have ever used.




Suggested Reads