Renderer

@braccato/core is a custom element, <braccato-lyrics>, that renders synced lyrics, highlights each syllable as it is sung, and scrolls to follow the song. It has no runtime dependencies. This page covers what most integrations need. The package README has the full reference.

Install

npm i @braccato/core

Imports

Importing @braccato/core/element registers the element. The package does not load its own CSS, so import the three stylesheets too. Load variables.css first, because the other two read from it.

import "@braccato/core/element";
import "@braccato/core/styles/variables.css";
import "@braccato/core/styles/lyrics.css";
import "@braccato/core/styles/instrumental.css";

Without the stylesheets, the lines are in the document but unstyled. The element renders into light DOM, not a shadow root, so your page CSS reaches the lines.

Required CSS

The element has no display of its own. Set it:

braccato-lyrics {
  display: block;
}

The renderer measures the space the first line needs to reach the scroll position, but lyrics.css only applies the bottom half. Add the top:

.blyrics-container {
  padding-top: var(--blyrics-padding-top, 2rem);
}

Scrolling

Autoscroll writes scrollTop on the nearest scrollable ancestor, starting at the element itself. An ancestor counts when its overflow-y is auto or scroll. If nothing scrolls, the renderer scrolls the whole page.

To make the element its own scroller, give it a height:

braccato-lyrics {
  display: block;
  height: 60vh;
  overflow-y: auto;
}

To scroll a different container, name it with host. Writing host rebuilds the view, so set it once during setup.

view.host = { getScrollElement: () => frame };

Pausing autoscroll when the user scrolls

The element does not listen for scroll events. Tell the renderer when the user scrolls, or autoscroll pulls the view back on the next frame and braccato:scroll-state never fires:

view.addEventListener("scroll", () => view.renderer?.noteUserScroll(), { passive: true });

Listen on whichever element actually scrolls.

Driving playback

From a media element

Set source to a CSS selector or to an HTMLMediaElement. A selector is resolved when the element connects, so the media element must already be in the document, or you set the property from script. While a source is bound, the element reads the time, paused state and playback rate on its own, and a line click seeks the media element. Writes to currentTime and playing are ignored in this mode.

view.source = document.querySelector("audio");

From your own clock

Without a source, write currentTime (in seconds) and playing on every animation frame. If the song plays at a rate other than 1, pass it as tickOptions.playbackRate.

function tick() {
  view.currentTime = player.currentTimeSeconds;
  view.playing = !player.paused;
  requestAnimationFrame(tick);
}
requestAnimationFrame(tick);

Properties

You can write every property before the element is in the document.

Property Type Description
lyrics Lyric[] | null The lines to render, usually from @braccato/parsers. An empty array clears the view
lyricsOptions object Build options: loaderVisible, noLyrics, language (a BCP 47 tag for the source lyrics)
source string | HTMLMediaElement | null The media element to follow, or a selector for it
currentTime number Playback position in seconds
playing boolean Whether the song is playing. A paused view animates differently
tickOptions object Clock offsets, passive scrolling, sample time and playbackRate
theme string A CSS stylesheet. See Theming
host object Overrides for what the renderer asks of the page, such as getScrollElement
renderer read-only The renderer underneath, for methods like noteUserScroll()
status read-only idle, rendering, theme-conflict or no-browsing-context

The source, theme, current-time and playing attributes set the matching properties. Properties never write back to attributes.

Events

All events bubble and are composed.

Event Detail When
braccato:lyrics-loaded { lineCount, syncType } Lyrics were applied, including an empty array
braccato:line-click { timeS } A line was clicked. With a bound source, the seek has already happened
braccato:scroll-state { userScrolling } Autoscroll stopped following the song, or started again
braccato:error { phase, error } Something failed while connecting, resolving the source, or applying lyrics or a theme

Errors are dispatched one microtask after they happen, so a listener added in the same task that inserts the element still receives them. If you attach listeners later, read status instead.

Theming

A theme is a CSS string written to theme. Settings that change behavior go in comments with a blyrics- prefix. Everything else is ordinary CSS.

view.theme = `
  /* blyrics-target-scroll-pos-ratio = 0.5; */
  /* blyrics-long-word-threshold = 900; */

  .blyrics-container {
    --blyrics-font-size: 3rem;
    --blyrics-lyric-active-color: white;
    --blyrics-lyric-inactive-color: rgb(255 255 255 / 0.3);
  }
`;

variables.css declares every --blyrics-* custom property. The class names (blyrics-container, blyrics--line, blyrics--active, blyrics--word and others) are public API. Import them from @braccato/core/constants instead of typing them. Each word also carries data-word-state (upcoming, active or past).

If you write themes in RICS, compile them to CSS with @braccato/rics first.

React example

The element is a normal custom element, so in React you set its properties through a ref. This example drives the clock from the player and seeks on line click.

import "@braccato/core/element";
import "@braccato/core/styles/variables.css";
import "@braccato/core/styles/lyrics.css";
import "@braccato/core/styles/instrumental.css";
import { useEffect, useRef } from "react";

export function Lyrics({ lyrics, getCurrentTime, getPlaying, onSeek }) {
  const ref = useRef(null);

  useEffect(() => {
    ref.current.lyrics = lyrics;
  }, [lyrics]);

  useEffect(() => {
    let frame;
    const tick = () => {
      ref.current.currentTime = getCurrentTime();
      ref.current.playing = getPlaying();
      frame = requestAnimationFrame(tick);
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [getCurrentTime, getPlaying]);

  useEffect(() => {
    const el = ref.current;
    const seek = (event) => onSeek(event.detail.timeS);
    const noteUserScroll = () => el.renderer?.noteUserScroll();
    el.addEventListener("braccato:line-click", seek);
    el.addEventListener("scroll", noteUserScroll, { passive: true });
    return () => {
      el.removeEventListener("braccato:line-click", seek);
      el.removeEventListener("scroll", noteUserScroll);
    };
  }, [onSeek]);

  return <braccato-lyrics ref={ref} style={{ display: "block", height: 600, overflowY: "auto" }} />;
}

Limits

  • One renderer per document. The theme and scroll padding are written per document, so two elements given different themes both report theme-conflict. Both keep rendering.
  • Load one copy of the package per page. The first copy to load registers the element names.
  • A browser extension's isolated world has no custom element registry. Use the element from the page's world, or call createLyricsRenderer from @braccato/core directly.