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.
Footer
: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);
*/
| Knob | Default | Description |
|---|---|---|
blyrics-disable-richsync | false | Render richsynced lyrics through the line-synced path instead, including line-synced fade-in |
blyrics-line-synced-animation-delay | 50 | Per-word delay for synced lyrics (ms) |
blyrics-lyric-ending-threshold-s | 0.5 | Seconds before line ends to consider it complete |
blyrics-early-scroll-consider-s | 0.54 | Independent future lookahead for scroll grouping (s) |
blyrics-debug-renderer | false | Enable debug overlay |
blyrics-debug-animation-timing | false | Log WAAPI lyric animation timing samples, learned offsets, and timing cleanup events |
blyrics-target-scroll-pos-ratio | 0.37 | Lyric position (0=top, 0.5=center, 1=bottom) |
blyrics-swipe-lead-ratio | 0.1 | Rich-sync swipe lead as a fraction of word duration |
blyrics-swipe-duration-ratio | 1.6 | Rich-sync swipe duration as a multiple of word duration |
blyrics-long-word-threshold | 1500 | Duration (ms) above which data-long-word is set |
blyrics-letter-wave | true | Experimental, 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-only | false | Treat “[Instrumental Only]” as no lyrics (enables fullscreen effect) |
blyrics-hide-credits | false | Never build the songwriter credits line |
blyrics-passive-scroll-enabled | true | Unsynced auto-scroll: enable/disable entirely (overrides user setting) |
blyrics-passive-scroll-seconds-per-line | 3.5 | Unsynced auto-scroll: seconds per line (scroll speed) |
blyrics-passive-scroll-bottom-pause-s | 1.5 | Unsynced auto-scroll: pause at bottom (s) |
blyrics-passive-scroll-reset-duration-s | 0.6 | Unsynced auto-scroll: scroll-back-to-top duration (s) |
blyrics-passive-scroll-top-pause-s | 0.8 | Unsynced auto-scroll: pause at top (s) |
blyrics-line-scroll-duration | 750ms | Per-line scroll animation duration |
blyrics-line-scroll-{above,active,below}-duration | side-specific tail defaults | Side-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-function | var(--blyrics-lyric-scroll-timing-function) | Shared line-scroll easing |
blyrics-line-scroll-{start,end}-easing | timing / linear | Keyframe easing |
blyrics-line-scroll-{above,active,below}-{start,end}-easing | shared keyframe easing | Side-specific keyframe easing; above/below swap on upward scrolls |
blyrics-line-scroll-translate-y-{start,end} | delta / 0px | Shared Y offsets |
blyrics-line-scroll-{above,active,below}-translate-y-{start,end} | shared offset | Side-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:
| Property | Set On | Description |
|---|---|---|
--blyrics-duration | .blyrics--word, .blyrics--instrumental | Duration of current element (ms) |
--blyrics-line-scroll-relative-index | visible animated lyric lines | Active line is 0, below lines are positive, above lines are negative |
--blyrics-line-scroll-abs-relative-index | visible animated lyric lines | Absolute relative index |
--blyrics-line-scroll-side | visible animated lyric lines | Direction-aware side: above, active, or below; above/below swap on upward scrolls |
--blyrics-line-scroll-delta-px | visible animated lyric lines | Signed scroll delta |
--blyrics-line-scroll-distance-px | visible animated lyric lines | Absolute scroll distance |
--blyrics-padding-top | :root | Calculated top spacer for scroll positioning |
--blyrics-padding-bottom | :root | Calculated 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
| Attribute | Values | Description |
|---|---|---|
data-sync | "richsync", "synced", "none" | Sync type |
data-loader-visible | "true", "false", or absent | Loader visibility |
data-no-lyrics | "true" or absent | No lyrics available |
data-credits-focused | present or absent | Songwriter credits hold the scroll focus |
Word Data Attributes
| Attribute | Description |
|---|---|
data-content | Word text |
data-time | Start time in seconds |
data-duration | Duration 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
| Attribute | Description |
|---|---|
[active] | Loader is visible |
[small-loader] | Compact loader (still searching) |
[no-sync-available] | Synced lyrics not found |
Selectors Reference
| Selector | Purpose |
|---|---|
.blyrics-container | Main lyrics wrapper |
.blyrics--line | Lyric line (div) |
.blyrics-line-main | Main lyric text row |
.blyrics-background-line | Primary background vocal row |
.blyrics-bidi-run | Inline text-flow wrapper for native browser bidi ordering |
.blyrics-highlight-run | aria-hidden layer over a text row that holds the word overlays |
.blyrics-bidi-sensitive | Applied to rows containing RTL script; makes word wrappers inline-flow for correct bidi wrapping |
.blyrics-word-group | Word/syllable group; inline-block for LTR-only rows and display: contents inside .blyrics-bidi-sensitive |
.blyrics--word | Word span |
.blyrics-word-highlight | Real active-color overlay; every timed word has one |
.blyrics-line-synced-word | Zero-duration line-synced word; fades in without rich-sync swipe |
.blyrics--active | Line is selected for scrolling. Runs on the scroll clock, which leads audio by --blyrics-scroll-timing-offset (0.5s default) |
.blyrics--animating | Line’s animations are live. Runs on the audio clock, so it survives until the line’s last word actually finishes |
.blyrics--paused | Playback is paused; on the line and on each word. Use it to freeze theme-authored CSS animations |
.blyrics-user-scrolling | User is scrolling manually |
.blyrics-rtl | RTL language support |
.blyrics--translated | Translation text |
.blyrics--romanized | Romanization text |
.blyrics--error | Error message |
.blyrics--instrumental | Instrumental 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-wordskips swipe and fades in at word time --blyrics-animate-highlight-swipe: 0keeps 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 containertransform; 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, translatebefore 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-highand--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: reducekeeps smooth scroll enabled but disables side-specific line-scroll differential effects
Keyframes
| Animation | Description |
|---|---|
blyrics-spin | Loader rotation |
blyrics-shimmer | Loading 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 */
}
Footer Card DOM
.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
| Selector | Purpose |
|---|---|
.blyrics-footer__unison | Outer wrapper, full-width inside footer |
.blyrics-footer__unison-card | Translucent card, hover lighten, opens unison page on click |
.blyrics-footer__unison-divider | 1px vertical separator between submitter and actions |
.blyrics-footer__unison-author | Submitter column |
.blyrics-footer__unison-author-row | Author name + tier pill row |
.blyrics-footer__author-name | Submitter handle (deterministic pet name from public key) |
.blyrics-footer__unison-author-label | ”submitted this” subtext |
.blyrics-footer__unison-actions-block | Right column with buttons + score line |
.blyrics-footer__unison-actions | Row of three vote/report buttons |
.blyrics-footer__unison-score-line | ”+12 score · 12 votes” line |
.blyrics-footer__vote | Base button (30px square in card, 32px in dock) |
.blyrics-footer__vote--active | Active vote (SVG fill-opacity becomes 1) |
.blyrics-footer__trust-tier | Tier pill, color via [data-tier] |
Trust Tier Colors
| Selector | Color 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 value | Anchor |
|---|---|
top-left | top: 64px; left: 0 |
top-center | top: 64px; left: 50% (translate -50%) |
top-right | top: 64px; left: 100% (translate -100%) |
bottom-left | top: calc(100% - 64px); left: 0 |
bottom-center | top: calc(100% - 64px); left: 50% |
bottom-right | top: calc(100% - 64px); left: 100% |
| Modifier | When applied |
|---|---|
.blyrics-unison-dock--hidden | Footer card is in viewport (avoids duplicate controls) |
.blyrics-unison-dock--idle-hidden | Player 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
| Selector | Purpose |
|---|---|
#blyrics-fs-column | Column under the album art holding the info row and the controls strip |
#blyrics-fs-info-row / .blyrics-fs-info-row | Song info plus the quick-actions trigger |
#blyrics-song-info | Title/artist/album block, also present outside fullscreen |
.blyrics-song-link | Artist and album links inside the info block; color: inherit, underline on hover |
.blyrics-fs-info-actions | Wrapper for the volume pill and the quick-actions trigger |
#blyrics-fs-controls | Controls strip: progress bar above the transport row |
.blyrics-fs-disc | Round control button base, shared by transport, rating, and more |
.blyrics-fs-more | Quick-actions trigger |
.blyrics-fs-play | Play/pause button, sized up from the rest |
.blyrics-fs-like / .blyrics-fs-dislike | Rating buttons |
.blyrics-fs-transport-slot / .blyrics-fs-transport-mid | Transport row layout |
.blyrics-fs-rate-icon | Holds the two rating icon layers |
.blyrics-fs-rate-layer--outline / --fill | Outline shows by default, fill shows when the rating is active |
State Attributes
| Attribute | When 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.
| Selector | Purpose |
|---|---|
.blyrics-progress | Wrapper; owns --blyrics-progress-dot-shadow |
.blyrics-progress__bar | Seek track; carries .dragging while scrubbing |
.blyrics-progress__fill-clip / .blyrics-progress__fill | Elapsed fill, scaled through transform |
.blyrics-progress__knob / .blyrics-progress__knob-dot | Draggable handle |
.blyrics-progress__times | Row holding the elapsed and end times |
.blyrics-progress__elapsed | Elapsed 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__icon | Speaker button and its icon; parts [data-part]: wave-low, wave-high, mute |
.blyrics-volume__bar / __fill / __knob | Volume 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 / Keyframe | Purpose |
|---|---|
.blyrics-control-pop | Scale pop on press (blyrics-fs-control-pop, blyrics-pip-control-pop) |
.blyrics-control-nudge-prev / -next | Previous and next icon nudge |
.blyrics-control-ripple-layer / .blyrics-control-ripple (--go) | Click ripple |
.blyrics-fs-rate-fire | Runs 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
| Attribute | When 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 line | current, previous, queued or gone |
data-stage-visible on a line | Line 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
| Hook | Notes |
|---|---|
.blyrics-karaoke-surface | Backdrop shared by both plates and the title card |
--blyrics-karaoke-video-scale | Unitless, default 1; e.g. 0.94 shrinks the video and overlay relative to the screen |
--blyrics-karaoke-plate-padding-block / -inline | Defaults 0.18em / 0.55em; padding of the plate around a sung line. Any unit; em follows the lyric size |
--blyrics-karaoke-card-padding-block / -inline | Defaults 0.36em / 0.8em; padding of the plate around the end card |
--blyrics-karaoke-plate-radius | Default 0.46em; corner radius of both plates and the title card. The line clip follows it |
--blyrics-font-size | Sets the karaoke lyric size, as elsewhere |
--blyrics-scale / --blyrics-active-scale | Forced to 1 on #blyrics-karaoke .blyrics-container; no line scaling in karaoke |
--blyrics-stage-opacity | Set 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
| Attribute | When 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
| Hook | Notes |
|---|---|
--blyrics-pip-video-aspect | Read-only. Video’s W / H, set inline on .blyrics-pip-shell; unset until known, rules fall back to 16 / 9 |
| Plate variables in the window | The 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-scale | Read-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-tint | Default rgb(0 0 0 / 28%), on .blyrics-pip-artwork__controls; background of control discs and the mode pill |
--blyrics-pip-control-backdrop | Default blur(8px), on .blyrics-pip-artwork__controls; backdrop-filter of control discs and the mode pill |
--blyrics-pip-control-size | Default 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 window | Same 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
- Use CSS variables over raw values
- Use display-p3 or oklch for wider color gamut
- Include
var(--noto-sans-universal)in font stacks for i18n - Test both modes - audio-only and video
- Test fullscreen and responsive breakpoints (936px, 615px)
- Handle
data-sync="none"- smooth transition when sync loads - Exclude translation/romanization -
:not(.blyrics--translated):not(.blyrics--romanized) - Handle user scroll -
.blyrics-user-scrolling - Prefer structural selectors such as
.blyrics-line-main,.blyrics-word-group, and data attributes; use.blyrics--activeonly for current-line affordances - Pick the right clock -
.blyrics--activefor anything tied to scrolling,.blyrics--animatingfor anything that must hold while a line is still being sung. Dimming past lines off.blyrics--activealone will dim them roughly half a second early - Respect RTL inline flow - do not force
.blyrics-bidi-sensitive .blyrics-word-groupor.blyrics-bidi-sensitive .blyrics--wordback toinline-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-startand--lyric-transition-amount-endunless you are replacing the karaoke highlight effect - Core DOM structure expectations
- YouTube Music element selectors (unless intentional)
Files Reference
| File | Purpose |
|---|---|
lyrics.css | Core lyrics styling and animations |
ytmusic.css | YouTube Music layout modifications |
themesong.css | ThemeSong extension compatibility |
disablestylizedanimations.css | Disables animations when toggled |
Existing Themes
Reference in public/css/themes/:
| Theme | Style |
|---|---|
Default.css | Minimal starting point |
Minimal.css | Opacity-based, no animations |
Spotlight.css | Blur effect on inactive lines |
Luxurious Glass.css | Glassmorphism, animated background |
Dynamic Background.css | Extensive YouTube Music UI customization |
Apple Music.css | Apple Music-inspired styling |
Harmony Glow.css | Glow effects |
Pastel.css | Soft pastel colors |