Theme guide for agents

Synced from STYLING-SKILL.md in the Better Lyrics repository when this site is built.

Essential reference for creating custom themes. For deep dives, see STYLING.md.

Quick Reference: CSS Variables

Colors

:root {
  --blyrics-text-color: color(display-p3 1 1 1 / 1);
  --blyrics-highlight-color: color(display-p3 1 1 1 / 0.5);
  --blyrics-active-opacity: 1;
  --blyrics-inactive-opacity: 0.3;
  --blyrics-translated-opacity: 0.6;
}

Typography

Leave font-family overrides unset for language-aware CJK defaults. If adding a font, declare --blyrics-font-family: "My Font", var(--noto-sans-universal), sans-serif on .blyrics--line, .blyrics--translated, .blyrics--romanized so the fallback resolves in each element’s language.

:root {
  --blyrics-font-size: 3rem;
  --blyrics-font-weight: 700;
  --blyrics-line-height: 1.333;
  --blyrics-translated-font-size: 2rem;
  --blyrics-translated-font-weight: 600;
  --blyrics-translated-font-family: var(--blyrics-font-family);
}

Animation

:root {
  --blyrics-animate-line-scale: 1;
  --blyrics-animate-word-wobble: 1;
  --blyrics-animate-highlight-swipe: 1;
  --blyrics-animate-highlight-glow: 1;
  --blyrics-animate-highlight-fade: 1;
  --blyrics-animate-scroll: 1;
  --blyrics-animate-instrumental: 1;
  --blyrics-loader-transition-duration: 0.6s;
  --blyrics-loader-transition-easing: cubic-bezier(0.22, 1, 0.36, 1);
  --blyrics-scale-transition-duration: 0.166s;
  --blyrics-line-enter-transform-from: scale(var(--blyrics-scale));
  --blyrics-line-enter-transform-to: scale(var(--blyrics-active-scale));
  --blyrics-line-exit-transform-from: scale(var(--blyrics-active-scale));
  --blyrics-line-exit-transform-to: scale(var(--blyrics-scale));
  --blyrics-line-enter-easing: ease;
  --blyrics-line-exit-easing: ease;
  --blyrics-lyric-highlight-fade-in-duration: 0.33s;
  --blyrics-lyric-highlight-fade-out-duration: 0.5s;
  --blyrics-lyric-highlight-fade-in-easing: ease;
  --blyrics-lyric-highlight-fade-out-easing: ease;
  --blyrics-highlight-swipe-easing: linear;
  --blyrics-highlight-swipe-start-from: -0.2;
  --blyrics-highlight-swipe-end-from: -0.1;
  --blyrics-highlight-swipe-start-to: 1.4;
  --blyrics-highlight-swipe-end-to: 1.5;
  --blyrics-highlight-glow-radius-from: 0.8rem;
  --blyrics-highlight-glow-radius-to: 0;
  --blyrics-highlight-glow-duration-ratio: 1.2;
  --blyrics-highlight-glow-min-duration: 1.2s;
  --blyrics-highlight-glow-easing: ease;
  --blyrics-wobble-duration: 1s;
  --blyrics-word-wobble-transform-from: scaleX(1);
  --blyrics-word-wobble-transform-peak: translateX(0.05em) scaleX(1.025);
  --blyrics-word-wobble-transform-settle: translateX(0) scaleX(1);
  --blyrics-word-wobble-transform-to: scaleX(1);
  --blyrics-word-wobble-peak-offset: 0.125;
  --blyrics-word-wobble-settle-offset: 0.75;
  --blyrics-word-wobble-easing: ease;
  --blyrics-word-wobble-peak-easing: ease-in-out;
  --blyrics-word-wobble-end-easing: ease-out;
  /* Letter wave (experimental, on by default; disable with blyrics-letter-wave = false) */
  --blyrics-letter-wave-transform: translateY(-0.05em);
  --blyrics-letter-wave-settle: translateY(-0.02em);
  --blyrics-letter-wave-emphasis-scale: 1.11;
  --blyrics-letter-wave-duration: 0.9s;
  --blyrics-letter-wave-rise-easing: ease-in-out;
  --blyrics-letter-wave-fall-easing: ease-out;
  --blyrics-instrumental-fill-fade-duration: 150ms;
  --blyrics-instrumental-fill-fade-easing: ease;
  --blyrics-instrumental-fill-transform-from: translateY(78%);
  --blyrics-instrumental-fill-transform-to: translateY(-4%);
  --blyrics-instrumental-fill-easing: linear;
  --blyrics-instrumental-wave-transform-from: scaleY(1.2);
  --blyrics-instrumental-wave-transform-to: scaleY(0.0001);
  --blyrics-instrumental-wave-easing: ease-in;
  --blyrics-instrumental-wave-path-high: path("M -4 3 Q 1 2 5 3 Q 10 4 14 3 Q 18 2 22 3 Q 26 4 30 3 L 30 4 L -4 4 Z");
  --blyrics-instrumental-wave-path-low: path("M -4 3 Q 1 4 5 3 Q 10 2 14 3 Q 18 4 22 3 Q 26 2 30 3 L 30 4 L -4 4 Z");
  --blyrics-instrumental-wave-oscillation-duration: 1.25s;
  --blyrics-instrumental-wave-oscillation-easing: ease-in-out;
  --blyrics-timing-offset: 0.115s;
  --blyrics-richsync-timing-offset: 0.150s;
  --blyrics-scroll-timing-offset: 0.5s;
  --blyrics-lyric-scroll-timing-function: cubic-bezier(0.86, 0, 0.2, 1);
}

Layout

:root {
  --blyrics-padding: 2rem;
  --blyrics-margin: 2rem;
  --blyrics-border-radius: 1000rem;
  --blyrics-padding-top: 0px;                    /* calculated automatically */
  --blyrics-padding-bottom: 0px;                 /* calculated automatically */
  --blyrics-panel-size: 50%;                     /* lyrics container width (audio mode) */
  --blyrics-video-panel-size: 30%;               /* lyrics container width (video mode) */
  --blyrics-fullscreen-panel-size: 66%;          /* lyrics container width (fullscreen audio) */
  --blyrics-fullscreen-video-panel-size: 25%;    /* lyrics container width (fullscreen video) */
}

Effects

:root {
  --blyrics-scale: 0.95;
  --blyrics-active-scale: 1;
  --blyrics-blur-amount: 30px;
  --blyrics-background-blur: 100px;
  --blyrics-background-saturate: 2;
}

--blyrics-blur-amount is the player bar’s frost. On the player page it is skipped while --blyrics-background-blur is exactly its 100px default (a style container query in general.css): over that background the frost changes no pixel, and Chromium would otherwise re-blur the whole bar in every frame the lyrics scroll near it. A theme that sharpens the background gets the frost back automatically.

:root {
  --blyrics-footer-bg-color: hsla(0, 0%, 100%, 0.1);
  --blyrics-footer-border-color: hsla(0, 0%, 100%, 0.1);
  --blyrics-footer-text-color: #aaa;
  --blyrics-footer-link-color: #fff;
  --blyrics-footer-font-family: Roboto, Arial, sans-serif;
  --blyrics-footer-font-size: 14px;
}

Songwriter Credits

:root {
  --blyrics-credits-label: "Written by"; /* localized by the extension */
  --blyrics-credits-font-size: max(0.4em, 12px);
  --blyrics-credits-opacity: 0.2; /* during the song */
  --blyrics-credits-focused-opacity: 0.85; /* after the last line ends */
}

p.blyrics-credits follows the last line. It is not a div, so .blyrics-container > div line rules skip it; style it through .blyrics-credits. Hide with display: none or the blyrics-hide-credits knob.

Configuration Knobs

Comment-based parameters that control JS behavior. Place anywhere in your theme:

/*
blyrics-disable-richsync = true;
blyrics-line-synced-animation-delay = 50;
blyrics-target-scroll-pos-ratio = 0.37;
blyrics-swipe-lead-ratio = 0.1;
blyrics-swipe-duration-ratio = 1.6;
blyrics-line-scroll-duration = 750ms;
blyrics-line-scroll-below-duration = calc(750ms + log(var(--blyrics-line-scroll-abs-relative-index) + 1, 2.71828) * 80ms + var(--blyrics-line-scroll-abs-relative-index) * 20ms);
blyrics-line-scroll-above-duration = calc(750ms + log(var(--blyrics-line-scroll-abs-relative-index) + 1) * 10ms);
*/
KnobDefaultDescription
blyrics-disable-richsyncfalseRender richsynced lyrics through the line-synced path instead, including line-synced fade-in
blyrics-line-synced-animation-delay50Per-word delay for synced lyrics (ms)
blyrics-lyric-ending-threshold-s0.5Seconds before line ends to consider it complete
blyrics-early-scroll-consider-s0.54Independent future lookahead for scroll grouping (s)
blyrics-debug-rendererfalseEnable debug overlay
blyrics-debug-animation-timingfalseLog WAAPI lyric animation timing samples, learned offsets, and timing cleanup events
blyrics-target-scroll-pos-ratio0.37Lyric position (0=top, 0.5=center, 1=bottom)
blyrics-swipe-lead-ratio0.1Rich-sync swipe lead as a fraction of word duration
blyrics-swipe-duration-ratio1.6Rich-sync swipe duration as a multiple of word duration
blyrics-long-word-threshold1500Duration (ms) above which data-long-word is set
blyrics-letter-wavetrueExperimental, on by default. Split words into per-letter spans and float each letter as it is sung, over the word wobble. Tune with --blyrics-letter-wave-*; long words also get a per-letter scale swell. Disable with blyrics-letter-wave = false
blyrics-hide-instrumental-onlyfalseTreat “[Instrumental Only]” as no lyrics (enables fullscreen effect)
blyrics-hide-creditsfalseNever build the songwriter credits line
blyrics-passive-scroll-enabledtrueUnsynced auto-scroll: enable/disable entirely (overrides user setting)
blyrics-passive-scroll-seconds-per-line3.5Unsynced auto-scroll: seconds per line (scroll speed)
blyrics-passive-scroll-bottom-pause-s1.5Unsynced auto-scroll: pause at bottom (s)
blyrics-passive-scroll-reset-duration-s0.6Unsynced auto-scroll: scroll-back-to-top duration (s)
blyrics-passive-scroll-top-pause-s0.8Unsynced auto-scroll: pause at top (s)
blyrics-line-scroll-duration750msPer-line scroll animation duration
blyrics-line-scroll-{above,active,below}-durationside-specific tail defaultsSide-specific scroll duration; above/below swap on upward scrolls. Defaults use uncapped relative-index formulas so farther trailing lines settle progressively later.
blyrics-line-scroll-timing-functionvar(--blyrics-lyric-scroll-timing-function)Shared line-scroll easing
blyrics-line-scroll-{start,end}-easingtiming / linearKeyframe easing
blyrics-line-scroll-{above,active,below}-{start,end}-easingshared keyframe easingSide-specific keyframe easing; above/below swap on upward scrolls
blyrics-line-scroll-translate-y-{start,end}delta / 0pxShared Y offsets
blyrics-line-scroll-{above,active,below}-translate-y-{start,end}shared offsetSide-specific Y offsets; above/below swap on upward scrolls

Scroll timing: blyrics-early-scroll-consider-s defaults to 0.54 seconds independently of animation duration. Lookahead only affects the target when another lyric triggers a scroll; entering the window alone does not scroll. Lines included in a committed scroll cannot trigger again at their own start. Seeking, resuming autoscroll and relayout may still reposition the view. There is no scroll gate or queue; blyrics-queue-scroll-ms is ignored and no timing equation needs balancing. --blyrics-lyric-scroll-duration, its --blyrics-lyric-transition-duration alias and the container transform transition have been removed. Use the line-scroll duration knobs instead; replace explicit references to the removed variables with a duration or your own custom property. Missing, invalid or nonpositive line durations fall back to an internal 750ms. Themes that relied on automatically derived lookahead should set it explicitly.

PiP scroll position: knobs are shared by the panel, fullscreen and PiP. To give PiP its own position, set --blyrics-target-scroll-pos-ratio under .blyrics-pip-shell: .blyrics-pip-shell .blyrics-container { --blyrics-target-scroll-pos-ratio: 0.37; }. It overrides blyrics-target-scroll-pos-ratio wherever it resolves. Plain number, clamped to 0 to 1, % not converted, non-numbers fall back to the knob. Read once per theme apply, and panel and fullscreen share one view, so a fullscreen-only selector won’t switch on toggle.

PiP scroll timing: PiP defaults --blyrics-scroll-timing-offset to 0s (at zero specificity), so a line moves only once the next one starts. A :root value doesn’t reach it; override under .blyrics-pip-shell .blyrics-container.

PiP lyrics swap: a new set of lyrics (new song, or another source from the dock) fades the old lines out and rises the on-screen lines in one after another. A source switch in the same song holds the old lyrics, dimmed, with .blyrics-pip-scroller[data-held] (default opacity: 0.35) until the next ones land; the loader shows only after 250ms. The fading copy is .blyrics-pip-lyrics__exit. The rise ends at the theme’s own line opacity and adds to its filter, so dimmed or blurred lines don’t snap. Reduced motion turns both into a short fade. PiP only. .blyrics-pip-lyrics[data-revealing] while lines rise. Tunable on .blyrics-pip-lyrics, read once per swap: --blyrics-pip-lyrics-hold-delay (250ms), -exit-duration (200ms), -reveal-duration (500ms), -reveal-delay (120ms), -reveal-stagger (40ms), -reveal-lines (all on screen, 0 = no rise), -reveal-distance (12px), -reveal-blur (3px), -reveal-easing (cubic-bezier(0.22, 1, 0.36, 1)); all prefixed --blyrics-pip-lyrics.

Dynamic Properties

Properties set by JS at runtime on individual elements:

PropertySet OnDescription
--blyrics-duration.blyrics--word, .blyrics--instrumentalDuration of current element (ms)
--blyrics-line-scroll-relative-indexvisible animated lyric linesActive line is 0, below lines are positive, above lines are negative
--blyrics-line-scroll-abs-relative-indexvisible animated lyric linesAbsolute relative index
--blyrics-line-scroll-sidevisible animated lyric linesDirection-aware side: above, active, or below; above/below swap on upward scrolls
--blyrics-line-scroll-delta-pxvisible animated lyric linesSigned scroll delta
--blyrics-line-scroll-distance-pxvisible animated lyric linesAbsolute scroll distance
--blyrics-padding-top:rootCalculated top spacer for scroll positioning
--blyrics-padding-bottom:rootCalculated bottom spacer for scroll positioning

Lyric timing is driven by element.animate().

DOM Structure

.blyrics-container [data-sync] [data-loader-visible] [data-no-lyrics]
├── .blyrics--line (div) [data-agent] [data-time] [data-duration] [data-line-number]
│   ├── .blyrics-line-main (div)
│   │   ├── .blyrics-bidi-run.blyrics-highlight-run (span, aria-hidden overlay)
│   │   │   └── .blyrics-word-group (span)
│   │   │       └── .blyrics--word.blyrics-word-highlight (span) [data-content] [data-time] [data-duration] [data-long-word] [data-word-state]
│   │   └── .blyrics-bidi-run (span)
│   │       └── .blyrics-word-group (span)
│   │           └── .blyrics--word (span) [data-content] [data-time] [data-duration] [data-long-word] [data-word-state]
│   ├── .blyrics-background-line (div, only when primary background vocals are present)
│   │   ├── .blyrics-bidi-run.blyrics-highlight-run (span, aria-hidden overlay)
│   │   │   └── .blyrics-word-group.blyrics-background-lyric
│   │   └── .blyrics-bidi-run (span)
│   │       └── .blyrics-word-group.blyrics-background-lyric
│   ├── .blyrics--romanized.blyrics-content-line
│   └── .blyrics--translated.blyrics-content-line
├── .blyrics--instrumental.blyrics--line [data-instrumental="true"]
│   └── .blyrics--instrumental-icon (svg)
│       ├── .blyrics--instrumental-bg (path)
│       ├── .blyrics--instrumental-fill (path)
│       └── .blyrics--wave-clip/.blyrics--wave-rect/.blyrics--wave-path
└── .blyrics-footer

Container Data Attributes

AttributeValuesDescription
data-sync"richsync", "synced", "none"Sync type
data-loader-visible"true", "false", or absentLoader visibility
data-no-lyrics"true" or absentNo lyrics available
data-credits-focusedpresent or absentSongwriter credits hold the scroll focus

Word Data Attributes

AttributeDescription
data-contentWord text
data-timeStart time in seconds
data-durationDuration in seconds
data-long-word"true" or absent - present when duration exceeds threshold
data-word-state"upcoming", "active", or "past" - whether the word is not yet reached, being sung, or already sung. Written on both layers, only on change

Loader Attributes

AttributeDescription
[active]Loader is visible
[small-loader]Compact loader (still searching)
[no-sync-available]Synced lyrics not found

Selectors Reference

SelectorPurpose
.blyrics-containerMain lyrics wrapper
.blyrics--lineLyric line (div)
.blyrics-line-mainMain lyric text row
.blyrics-background-linePrimary background vocal row
.blyrics-bidi-runInline text-flow wrapper for native browser bidi ordering
.blyrics-highlight-runaria-hidden layer over a text row that holds the word overlays
.blyrics-bidi-sensitiveApplied to rows containing RTL script; makes word wrappers inline-flow for correct bidi wrapping
.blyrics-word-groupWord/syllable group; inline-block for LTR-only rows and display: contents inside .blyrics-bidi-sensitive
.blyrics--wordWord span
.blyrics-word-highlightReal active-color overlay; every timed word has one
.blyrics-line-synced-wordZero-duration line-synced word; fades in without rich-sync swipe
.blyrics--activeLine is selected for scrolling. Runs on the scroll clock, which leads audio by --blyrics-scroll-timing-offset (0.5s default)
.blyrics--animatingLine’s animations are live. Runs on the audio clock, so it survives until the line’s last word actually finishes
.blyrics--pausedPlayback is paused; on the line and on each word. Use it to freeze theme-authored CSS animations
.blyrics-user-scrollingUser is scrolling manually
.blyrics-rtlRTL language support
.blyrics--translatedTranslation text
.blyrics--romanizedRomanization text
.blyrics--errorError message
.blyrics--instrumentalInstrumental break
[data-agent="v1"]Primary voice (left)
[data-agent="v2"], [data-agent="v3"]Secondary/tertiary voice (right)
[data-agent="v1000"]Duet/chorus (centered)
[data-long-word]Long sustained word
[data-word-state="upcoming"]Word not yet reached
[data-word-state="active"]Word being sung
[data-word-state="past"]Word already sung

Animation System

Every timed word has a real .blyrics-word-highlight overlay with background-clip: text. A row’s overlays sit in .blyrics-highlight-run, an aria-hidden copy laid over the visible text so both share one layout and wrap the same way:

.blyrics--word.blyrics-word-highlight {
  color: transparent;
  background-image: linear-gradient(90deg, var(--blyrics-lyric-active-color) ..., transparent ...);
  background-clip: text;
}

Timing uses the Web Animations API:

  • Rich-sync swipe starts at wordStart - blyrics-swipe-lead-ratio * wordDuration
  • Rich-sync swipe duration is blyrics-swipe-duration-ratio * wordDuration
  • Rich-sync word-timed highlights become visible instantly at wordStart; they do not use the highlight fade-in duration
  • Line-synced .blyrics-line-synced-word skips swipe and fades in at word time
  • --blyrics-animate-highlight-swipe: 0 keeps rich-sync timing but makes each word fully highlighted instantly instead of fading like line-synced lyrics
  • Glow lasts max(wordDuration * --blyrics-highlight-glow-duration-ratio, --blyrics-highlight-glow-min-duration)
  • Lyric line scale and scroll smoothing also use element.animate()
  • Scroll smoothing uses per-line translate, not container transform; JS automatically animates lines visible in the previous or current viewport and provides relative index, absolute relative index, signed scroll delta, and absolute scroll distance as CSS variables
  • Visible lyric lines receive inline will-change: transform, translate before scroll animations start
  • Per-line scroll effects can overlap with additive Web Animations (composite: "add"); new lyric groups scroll immediately while earlier animations continue; line durations control visual motion independently of lookahead
  • The instrumental wave morphs between --blyrics-instrumental-wave-path-high and --blyrics-instrumental-wave-path-low; both must use the same path commands in the same order with the same argument counts, or the ripple snaps at the halfway point instead of morphing
  • Visual keyframe values, effect enable flags, durations, and easing are CSS variables; JS still owns scheduling, pause/resume, seeking, and cancellation
  • prefers-reduced-motion: reduce keeps smooth scroll enabled but disables side-specific line-scroll differential effects

Keyframes

AnimationDescription
blyrics-spinLoader rotation
blyrics-shimmerLoading text shimmer

Unison Submitter Card and Floating Dock

Injected on the YouTube Music page when the Unison provider is active. Themeable through your custom CSS like the rest of this surface.

New Variables

:root {
  --blyrics-vote-hover-color: hsla(0, 0%, 100%, 0.2);
  --blyrics-small-border-radius: 1rem;
  --blyrics-fullscreen-bottom-dock-shift: -24px; /* Y-shift for bottom dock in fullscreen */
}
.blyrics-footer__unison
└── .blyrics-footer__container.blyrics-footer__unison-card
    ├── .blyrics-footer__unison-author (only when there is a submitter)
    │   ├── .blyrics-footer__unison-author-row
    │   │   ├── strong.blyrics-footer__author-name
    │   │   └── span.blyrics-footer__trust-tier [data-tier]
    │   └── .blyrics-footer__unison-author-label
    ├── .blyrics-footer__unison-divider (only when submitter is present)
    └── .blyrics-footer__unison-actions-block
        ├── .blyrics-footer__unison-actions
        │   ├── button.blyrics-footer__vote (upvote)
        │   ├── button.blyrics-footer__vote (downvote)
        │   └── button.blyrics-footer__vote (report)
        └── .blyrics-footer__unison-score-line
SelectorPurpose
.blyrics-footer__unisonOuter wrapper, full-width inside footer
.blyrics-footer__unison-cardTranslucent card, hover lighten, opens unison page on click
.blyrics-footer__unison-divider1px vertical separator between submitter and actions
.blyrics-footer__unison-authorSubmitter column
.blyrics-footer__unison-author-rowAuthor name + tier pill row
.blyrics-footer__author-nameSubmitter handle (deterministic pet name from public key)
.blyrics-footer__unison-author-label”submitted this” subtext
.blyrics-footer__unison-actions-blockRight column with buttons + score line
.blyrics-footer__unison-actionsRow of three vote/report buttons
.blyrics-footer__unison-score-line”+12 score · 12 votes” line
.blyrics-footer__voteBase button (30px square in card, 32px in dock)
.blyrics-footer__vote--activeActive vote (SVG fill-opacity becomes 1)
.blyrics-footer__trust-tierTier pill, color via [data-tier]

Trust Tier Colors

SelectorColor family
.blyrics-footer__trust-tier[data-tier="new"]Blue
.blyrics-footer__trust-tier[data-tier="trusted"]Green
.blyrics-footer__trust-tier[data-tier="veteran"]Purple
.blyrics-footer__trust-tier[data-tier="expert"]Gold

Floating Dock

.blyrics-unison-dock [data-position]
└── .blyrics-unison-dock__inner
    └── .blyrics-footer__vote (×3)

Mounted inside #side-panel. pointer-events: none on the wrapper, auto on __inner.

Position valueAnchor
top-lefttop: 64px; left: 0
top-centertop: 64px; left: 50% (translate -50%)
top-righttop: 64px; left: 100% (translate -100%)
bottom-lefttop: calc(100% - 64px); left: 0
bottom-centertop: calc(100% - 64px); left: 50%
bottom-righttop: calc(100% - 64px); left: 100%
ModifierWhen applied
.blyrics-unison-dock--hiddenFooter card is in viewport (avoids duplicate controls)
.blyrics-unison-dock--idle-hiddenPlayer is idle in fullscreen

Animates transform, opacity, filter: blur(8px) over 320ms.

Layout Adjustments

/* dock shifts down 72px when autoscroll resume button is visible */
#side-panel:has(.autoscroll-resume-button:not([autoscroll-hidden="true"])) .blyrics-unison-dock[data-position="top-center"] {
  --dock-y-shift: 72px;
}

/* hide dock when side panel shows non-lyrics content */
#side-panel:has(#tab-renderer:not([page-type="MUSIC_PAGE_TYPE_TRACK_LYRICS"])) .blyrics-unison-dock { ... }

/* in fullscreen: top-anchored dock hidden, bottom-anchored pinned above player bar */
#layout[player-fullscreened]:not([blyrics-dfs]) .blyrics-unison-dock[data-position^="top-"] { ... }
#layout[player-fullscreened]:not([blyrics-dfs]) .blyrics-unison-dock[data-position^="bottom-"] {
  --dock-y-shift: var(--blyrics-fullscreen-bottom-dock-shift, -24px);
}

Override --blyrics-fullscreen-bottom-dock-shift to tune the lift.

Fullscreen Player Controls

Injected under the album art in fullscreen (audio) mode: song info, a seekable progress bar, transport, like/dislike, and a quick-actions trigger. The progress bar and the press animations are shared with the floating window. Hidden outside fullscreen, during ads, and while the fullscreen-controls setting is off.

DOM Structure

#blyrics-fs-column                          (fullscreen only, width tracks the player element)
├── #blyrics-fs-info-row .blyrics-fs-info-row
│   ├── #blyrics-song-info
│   │   ├── p#blyrics-title
│   │   └── p#blyrics-artist
│   │       ├── a.blyrics-song-link          (one per artist, links to its channel)
│   │       └── span#blyrics-album
│   │           └── a.blyrics-song-link      (album, links to the album page)
│   └── .blyrics-fs-info-actions
│       ├── .blyrics-volume                          (volume pill)
│       └── button.blyrics-fs-disc.blyrics-fs-more   (quick-actions trigger)
└── #blyrics-fs-controls
    ├── .blyrics-progress                    (see Progress Bar)
    └── .blyrics-fs-transport-slot
        ├── button.blyrics-fs-disc.blyrics-fs-like
        ├── .blyrics-fs-transport-mid
        │   ├── button.blyrics-fs-disc       (previous)
        │   ├── button.blyrics-fs-disc.blyrics-fs-play
        │   └── button.blyrics-fs-disc       (next)
        └── button.blyrics-fs-disc.blyrics-fs-dislike

Selectors

SelectorPurpose
#blyrics-fs-columnColumn under the album art holding the info row and the controls strip
#blyrics-fs-info-row / .blyrics-fs-info-rowSong info plus the quick-actions trigger
#blyrics-song-infoTitle/artist/album block, also present outside fullscreen
.blyrics-song-linkArtist and album links inside the info block; color: inherit, underline on hover
.blyrics-fs-info-actionsWrapper for the volume pill and the quick-actions trigger
#blyrics-fs-controlsControls strip: progress bar above the transport row
.blyrics-fs-discRound control button base, shared by transport, rating, and more
.blyrics-fs-moreQuick-actions trigger
.blyrics-fs-playPlay/pause button, sized up from the rest
.blyrics-fs-like / .blyrics-fs-dislikeRating buttons
.blyrics-fs-transport-slot / .blyrics-fs-transport-midTransport row layout
.blyrics-fs-rate-iconHolds the two rating icon layers
.blyrics-fs-rate-layer--outline / --fillOutline shows by default, fill shows when the rating is active

State Attributes

AttributeWhen applied
.blyrics-fs-disc[data-active]Like or dislike is the current rating; the fill layer shows
.blyrics-fs-disc[data-menu-open]The quick-actions menu is open; keeps the trigger’s hover background
#blyrics-fs-controls[data-ad]An ad is playing; the controls strip is hidden
html[blyrics-no-fs-controls]Fullscreen-controls setting is off; hides #blyrics-fs-controls and .blyrics-fs-info-actions
html[blyrics-fs-controls-pinned]Fullscreen-controls setting is On; the controls stay visible while the player bar shows

Progress Bar

Shared with the floating window, which scopes it under .blyrics-pip-progress.

SelectorPurpose
.blyrics-progressWrapper; owns --blyrics-progress-dot-shadow
.blyrics-progress__barSeek track; carries .dragging while scrubbing
.blyrics-progress__fill-clip / .blyrics-progress__fillElapsed fill, scaled through transform
.blyrics-progress__knob / .blyrics-progress__knob-dotDraggable handle
.blyrics-progress__timesRow holding the elapsed and end times
.blyrics-progress__elapsedElapsed time
.blyrics-progress__end[data-mode]End time; total shows the duration, remaining shows the time left, click toggles
.blyrics-volume[data-open][data-muted][data-level]Volume pill; data-level is high, low or muted
.blyrics-volume__button / .blyrics-volume__iconSpeaker button and its icon; parts [data-part]: wave-low, wave-high, mute
.blyrics-volume__bar / __fill / __knobVolume slider

Variables on .blyrics-volume: --blyrics-volume-width, --blyrics-volume-rest-opacity, --blyrics-volume-knob-size. Better Lyrics sets --blyrics-volume-level at runtime, so treat it as read only.

Press Feedback and Like Burst

Applied briefly on interaction, shared by fullscreen and the floating window.

Class / KeyframePurpose
.blyrics-control-popScale pop on press (blyrics-fs-control-pop, blyrics-pip-control-pop)
.blyrics-control-nudge-prev / -nextPrevious and next icon nudge
.blyrics-control-ripple-layer / .blyrics-control-ripple (--go)Click ripple
.blyrics-fs-rate-fireRuns the like pop and iris (blyrics-fs-rate-pop, blyrics-fs-rate-iris)
.blyrics-fs-rate-burst / -ray / -dot (--go)Rays and dots that fly out on a like (blyrics-fs-rate-ray, blyrics-fs-rate-dot)

Karaoke Subtitles

With the “Karaoke subtitles” option on (the default) and a music video in fullscreen, the video fills the screen and synced lines show over it one at a time on a blurred plate. The side panel and #blyrics-fs-column stay hidden the whole time karaoke owns the fullscreen video, even while lyrics load or when there are none or only unsynced ones. Lines keep the theme’s look; the stage only decides where they go.

DOM Structure

#blyrics-karaoke                                    (fixed overlay on body)
├── .blyrics-karaoke__stage
│   ├── .blyrics-karaoke__plate.blyrics-karaoke-surface    (two plates, swapped when the line moves far)
│   ├── .blyrics-karaoke__plate.blyrics-karaoke-surface
│   └── .blyrics-karaoke__mount
│       └── .blyrics-container[data-layout="stage"]
│           ├── .blyrics--line
│           └── .blyrics-credits                    (end card)
│               └── .blyrics-karaoke-source         (provider name and sync icon)
└── .blyrics-karaoke__title                         (intro title card)
    └── .blyrics-karaoke__card.blyrics-karaoke-surface
        ├── p.blyrics-karaoke__card-title
        ├── p.blyrics-karaoke__card-artist
        └── p.blyrics-karaoke__card-credit

State Attributes

AttributeWhen applied
ytmusic-app-layout[blyrics-karaoke] / #player-page[blyrics-karaoke]Karaoke owns the fullscreen video layout
#blyrics-karaoke[data-bar]Player bar is showing; the stage lifts above it
#blyrics-karaoke[data-title-card]Intro title card is showing; the stage is hidden
#blyrics-karaoke[data-card]title or end while that card shows; absent for sung lines
.blyrics-karaoke__plate[data-plate]Plate is shown behind the current line
.blyrics-karaoke__plate[data-end-card]Plate is behind the end card (credits and source); kept while it fades out
data-stage-role on a linecurrent, previous, queued or gone
data-stage-visible on a lineLine is on screen; otherwise visibility: hidden
.blyrics-container[data-stage-duet]Sung lines use both sides: v1 left, v2/v3 right, v1000 centred. A one-singer song stays centred

Theme Hooks

HookNotes
.blyrics-karaoke-surfaceBackdrop shared by both plates and the title card
--blyrics-karaoke-video-scaleUnitless, default 1; e.g. 0.94 shrinks the video and overlay relative to the screen
--blyrics-karaoke-plate-padding-block / -inlineDefaults 0.18em / 0.55em; padding of the plate around a sung line. Any unit; em follows the lyric size
--blyrics-karaoke-card-padding-block / -inlineDefaults 0.36em / 0.8em; padding of the plate around the end card
--blyrics-karaoke-plate-radiusDefault 0.46em; corner radius of both plates and the title card. The line clip follows it
--blyrics-font-sizeSets the karaoke lyric size, as elsewhere
--blyrics-scale / --blyrics-active-scaleForced to 1 on #blyrics-karaoke .blyrics-container; no line scaling in karaoke
--blyrics-stage-opacitySet by the engine; stage line opacity reads it with !important
ytmusic-player-page[video-mode][blyrics-karaoke] :is(#player, .html5-video-player)Background is transparent !important, so bars around a non-matching video show the theme or album art backdrop; set #000 !important to restore black

Never set opacity on stage lines. It is overridden, so a theme cannot show a hidden line or hold a leaving one.

:root {
  --blyrics-karaoke-video-scale: 0.94;
}

#blyrics-karaoke .blyrics-karaoke-surface {
  border-radius: 0.3em;
  background: rgb(0 0 0 / 70%);
  backdrop-filter: blur(12px);
}

Floating Window Music Video

With “Show music video” on and YouTube Music in video mode, the floating window plays the video in the art slot. With karaoke subtitles also on and synced lyrics, it shows a full-window karaoke stage over the video instead. Ads are never mirrored: the artwork and lyrics fade out and a countdown intermission shows.

DOM Structure

.blyrics-pip-shell                                  ([data-video], [data-layout], [data-instant])
├── .blyrics-pip-art-col
│   └── .blyrics-pip-artwork                        ([data-video-face])
│       ├── .blyrics-pip-artwork__card
│       │   └── .blyrics-pip-artwork__face           (front and back)
│       │       └── video.blyrics-pip-artwork__music-video
│       └── .blyrics-pip-artwork__controls
│           ├── .blyrics-pip-artwork__chrome
│           │   ├── .blyrics-pip-mode-toggle
│           │   │   └── button.blyrics-pip-mode-toggle__option   (song, video)
│           │   └── button.blyrics-pip-artwork__control.blyrics-pip-artwork__control--close
│           ├── button.blyrics-pip-artwork__control.blyrics-pip-artwork__control--like   ([data-active] when liked)
│           └── .blyrics-volume                     (same pill as fullscreen)
├── .blyrics-pip-stage
│   ├── video.blyrics-pip-stage__video
│   └── #blyrics-karaoke                            (same tree as fullscreen karaoke)
└── .blyrics-pip-intermission
    ├── .blyrics-pip-intermission__row
    │   ├── .blyrics-pip-intermission__mark
    │   └── p.blyrics-pip-intermission__label
    ├── .blyrics-pip-intermission__count
    │   └── .blyrics-pip-intermission__glyph        ([data-digit] on digits)
    ├── .blyrics-pip-intermission__bar
    │   └── .blyrics-pip-intermission__fill         (drains through transform: scaleX)
    └── .blyrics-pip-intermission__next
        ├── .blyrics-pip-intermission__thumb
        └── span                                    ("Then <b>song</b>")

State Attributes

AttributeWhen applied
.blyrics-pip-shell[data-video="on"]Video is playing in the window
.blyrics-pip-shell[data-video="ad"]Ad is playing; intermission shows, art column and lyrics hidden
.blyrics-pip-shell[data-video="off"]Option off, song mode, or no video track yet
.blyrics-pip-shell[data-layout="stage"]Karaoke stage over the video: option on, video mode, karaoke on, synced lyrics
.blyrics-pip-shell[data-stage-card]title or end while a karaoke card shows in the stage; --blyrics-pip-card-height (read-only) holds its height. The transport stays above the card and the header hides during the title card; at 340px wide or less (not tall windows) the mode pill hides too
.blyrics-pip-shell[data-instant]Song/video switch in progress; transitions forced off until it settles
.blyrics-pip-artwork[data-video-face]Visible artwork face holds the video
.blyrics-pip-mode-toggle[hidden]Track has no song/video switch; also hidden in windows under 130px tall
.blyrics-pip-mode-toggle__option[aria-pressed="true"]Current mode
.blyrics-pip-intermission__count[hidden], __bar[hidden], __next[hidden]No ad time or next title to show

Theme Hooks

HookNotes
--blyrics-pip-video-aspectRead-only. Video’s W / H, set inline on .blyrics-pip-shell; unset until known, rules fall back to 16 / 9
Plate variables in the windowThe window sets them on .blyrics-pip-shell[data-layout="stage"]: radius 0.75rem, line padding 0.5rem / 0.75rem, end card padding 0.5rem / 1rem. Override them there in rem or px; the window reads them there to keep the plate clear of the progress row and transport
--blyrics-pip-karaoke-text-scaleRead-only. Unitless, set inline on .blyrics-pip-shell on resize; stage --blyrics-font-size and --blyrics-translated-font-size are multiplied by it. The transport uses it too, so overriding moves text but not controls
--blyrics-pip-control-tintDefault rgb(0 0 0 / 28%), on .blyrics-pip-artwork__controls; background of control discs and the mode pill
--blyrics-pip-control-backdropDefault blur(8px), on .blyrics-pip-artwork__controls; backdrop-filter of control discs and the mode pill
--blyrics-pip-control-sizeDefault 36px (44px in tall windows), on .blyrics-pip-artwork; previous and next button size, play and pause is 1.25 times larger. Does not change with window size: the artwork grows so the pill, close button and transport fit inside it at its aspect ratio, and short windows hide the progress row. Normal layout only; the stage sizes its own transport
#blyrics-karaoke in the windowSame plate, title card, end card classes and attributes as fullscreen; prefix .blyrics-pip-shell to target the window only

Internal, do not set: --blyrics-pip-stage-width, -stage-height, -progress-inset, -progress-height, -plate-gap, -plate-overhang, -plate-top, -frame-bottom-gap, -header-bottom, -transport-margin, -transport-y, -karaoke-font-size, -karaoke-translated-font-size. They derive from each other and place the transport between the header and the plate. Also internal, normal layout: --blyrics-pip-artwork-aspect, -artwork-min-height, -artwork-min-width, -chrome-height, -transport-edge; they size the artwork minimum.

Tall windows (max-aspect-ratio: 4/5) put the video at the top and the lines in the band under it, with no plate background.

.blyrics-pip-shell #blyrics-karaoke .blyrics-karaoke-surface {
  background: rgb(0 0 0 / 55%);
}

.blyrics-pip-artwork__controls {
  --blyrics-pip-control-tint: rgb(255 255 255 / 12%);
}

.blyrics-pip-intermission__fill {
  background: var(--blyrics-lyric-active-color);
}

Theme Patterns

1. Disable Default Animations

:root {
  --blyrics-animate-line-scale: 0;
  --blyrics-animate-word-wobble: 0;
  --blyrics-animate-highlight-swipe: 0;
  --blyrics-animate-highlight-glow: 0;
  --blyrics-animate-scroll: 0;
  --blyrics-animate-instrumental: 0;
  --blyrics-scale: 1;
  --blyrics-active-scale: 1;
}

2. Opacity-Based Active State

.blyrics-container > div {
  opacity: 0.35;
  transform: none !important;
  transition: opacity 0.4s ease-out !important;
}
.blyrics-container > div.blyrics--active {
  opacity: 1;
}

3. Blur Inactive Lines

.blyrics-container > div {
  opacity: 0.2;
  filter: blur(6px);
  transition: opacity 0.7s, filter 0.7s, transform 1.66s;
}
.blyrics-container > div.blyrics--active:not(:empty) {
  opacity: 1;
  filter: blur(0px);
}
.blyrics-user-scrolling > div {
  opacity: 1 !important;
  filter: blur(0px) !important;
}
.blyrics-container[data-sync="none"] > div {
  opacity: 1;
  filter: none;
}

4. Duration-Based Timing

.blyrics-container > div {
  transition: filter calc(var(--blyrics-duration) / 2),
              opacity calc(var(--blyrics-duration) / 2);
}

5. Custom Font Import

@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@200..800&display=swap');
.blyrics-container {
  font-family: 'Bricolage Grotesque', var(--noto-sans-universal), sans-serif;
}

6. Theme Variables

:root {
  --my-theme-bg: #1a1a1a;
  --my-theme-text: #e0e0e0;
  --my-theme-accent: #d4a5a5;
}

7. Background Customization

ytmusic-player-page:before {
  background: linear-gradient(to right, rgba(26,26,26,0.75), rgba(26,26,26,0.75)),
              var(--blyrics-background-img);
  filter: blur(50px) saturate(0.8);
}

8. Glassmorphism

#side-panel {
  backdrop-filter: blur(20px) !important;
  background-color: rgba(0, 0, 0, 0.25) !important;
  box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 25px rgba(255,255,255,0.12) inset !important;
}

9. Animated Background

ytmusic-player-page::before {
  filter: blur(70px) saturate(3) brightness(70%);
  animation: slowRotate 15s linear infinite;
}
@keyframes slowRotate {
  from { transform: scale(1.7) rotate(0deg); }
  to { transform: scale(1.7) rotate(360deg); }
}

10. Underline Active Line

.blyrics-container > div::after {
  content: '';
  position: absolute;
  left: 50%; bottom: 10px;
  height: 2px; width: 50%;
  transform: translateX(-50%) scaleX(0);
  background: linear-gradient(90deg, transparent, hsla(0,0%,100%,0.4), transparent);
  transition: transform 0.5s cubic-bezier(0.86, 0, 0.07, 1);
}
.blyrics-container > div.blyrics--active::after {
  transform: translateX(-50%) scaleX(1);
}

11. User Scroll State

.blyrics-user-scrolling > div {
  opacity: 1 !important;
  filter: blur(0px) !important;
}
.blyrics-container:not(:has(.blyrics--active)) > div {
  opacity: 1;
  filter: none;
}

12. Modern Color Spaces

:root {
  --blyrics-lyric-inactive-color: oklch(1 0 0 / 0.35);
  --blyrics-lyric-active-color: oklch(1 0 0 / 1);
}

13. Instrumental Customization

.blyrics--instrumental-icon {
  width: 4rem;
  height: 4rem;
}
.blyrics--instrumental-bg {
  fill: rgba(255, 255, 255, 0.3);
}
.blyrics--instrumental-fill {
  fill: rgba(255, 255, 255, 1);
}

14. Long Word Glow

Target sustained notes for special effects:

/* Set threshold in knobs */
/* blyrics-long-word-threshold = 1500; */

.blyrics-word-highlight[data-long-word] {
  --blyrics-glow-color: color(display-p3 1 0.8 0.3 / 1);
}

--blyrics-glow-color resolves per word, so different long words can glow different colors. Tune the blur with --blyrics-highlight-glow-radius-from / --blyrics-highlight-glow-radius-to while keeping per-word color. Overriding the full --blyrics-highlight-glow-filter-from / --blyrics-highlight-glow-filter-to instead resolves the color once at the container, so every word shares one glow color.

15. Pause Behavior

Playback pause is handled by pausing the active Web Animations API animations in JavaScript. The engine also puts .blyrics--paused on the line and on each of its words, so theme-authored CSS animations and transitions can freeze in step:

.blyrics--paused .my-shimmer {
  animation-play-state: paused;
}

16. Per-Letter Wave (Experimental)

On by default. Each letter of a word floats in turn as it is sung, on top of the word wobble. Disable it with blyrics-letter-wave = false, or tune it:

.blyrics-container {
  --blyrics-letter-wave-transform: translateY(-0.05em); /* crest lift */
  --blyrics-letter-wave-settle: translateY(-0.02em); /* rest it eases back to */
  --blyrics-letter-wave-emphasis-scale: 1.11; /* long-word letter swell, 1 turns it off */
}

It layers on the word wobble rather than replacing it, so the default scaleX pop stays. To make the letters carry all the motion instead, set the word wobble to identity (--blyrics-word-wobble-transform-*: translateY(0)). It follows --blyrics-animate-word-wobble, so reduced motion turns it off. The split multiplies the DOM per character and reruns the karaoke sweep per letter, so a theme that does not want the cost turns it off with blyrics-letter-wave = false.

17. Per-Word State (Karaoke)

data-word-state marks whether each word is upcoming, active, or past. It is written on both the base .blyrics--word and its highlight overlay, and only when a word’s state changes, so a theme can flip whole words as they are sung with no per-frame cost:

.blyrics--word {
  color: var(--blyrics-lyric-inactive-color);
  transition: color 180ms ease;
}

.blyrics--word[data-word-state="active"],
.blyrics--word[data-word-state="past"] {
  color: var(--blyrics-lyric-active-color);
}

A line-synced word, which has no duration of its own, is active from its start until the next word begins. To make this the whole reveal instead of layering over the swept overlay, stand the overlay down with .blyrics-word-highlight { display: none; }.

Best Practices

  1. Use CSS variables over raw values
  2. Use display-p3 or oklch for wider color gamut
  3. Include var(--noto-sans-universal) in font stacks for i18n
  4. Test both modes - audio-only and video
  5. Test fullscreen and responsive breakpoints (936px, 615px)
  6. Handle data-sync="none" - smooth transition when sync loads
  7. Exclude translation/romanization - :not(.blyrics--translated):not(.blyrics--romanized)
  8. Handle user scroll - .blyrics-user-scrolling
  9. Prefer structural selectors such as .blyrics-line-main, .blyrics-word-group, and data attributes; use .blyrics--active only for current-line affordances
  10. Pick the right clock - .blyrics--active for anything tied to scrolling, .blyrics--animating for anything that must hold while a line is still being sung. Dimming past lines off .blyrics--active alone will dim them roughly half a second early
  11. Respect RTL inline flow - do not force .blyrics-bidi-sensitive .blyrics-word-group or .blyrics-bidi-sensitive .blyrics--word back to inline-block; word wobble transforms should be treated as unavailable on RTL-sensitive rows

Do NOT Modify

  • --noto-sans-universal - International font fallback chain
  • --blyrics-gradient-stops - Complex fullscreen gradient
  • Core animation variables such as --lyric-transition-amount-start and --lyric-transition-amount-end unless you are replacing the karaoke highlight effect
  • Core DOM structure expectations
  • YouTube Music element selectors (unless intentional)

Files Reference

FilePurpose
lyrics.cssCore lyrics styling and animations
ytmusic.cssYouTube Music layout modifications
themesong.cssThemeSong extension compatibility
disablestylizedanimations.cssDisables animations when toggled

Existing Themes

Reference in public/css/themes/:

ThemeStyle
Default.cssMinimal starting point
Minimal.cssOpacity-based, no animations
Spotlight.cssBlur effect on inactive lines
Luxurious Glass.cssGlassmorphism, animated background
Dynamic Background.cssExtensive YouTube Music UI customization
Apple Music.cssApple Music-inspired styling
Harmony Glow.cssGlow effects
Pastel.cssSoft pastel colors