Adaptive chrome color for Zen Browser — the URL bar, sidebar, titlebar, and outer rim tint to match the active page's background.
Built for heavy-tab sessions. Tested at 1,200+ open tabs without measurable tab-switch lag.
Fully tunable from a Sine settings panel — tint strength (overlay over your own Zen theme/gradient), saturation, lightness clamp, and per-site rules. See Configuration.
The URL bar, sidebar, titlebar, and outer rim tint to the active page as you switch tabs.
Live mode: with a video playing, the chrome continuously tracks the scene's color.
Stock Zen uses a single accent color across the chrome. On a vibrant page that looks fine; on a strongly-themed page the contrast feels off and the chrome reads as separate from the content. Tinting the chrome to the page bg merges them visually — like Arc and Dia do.
Other adaptive-color mods exist, but they tend to feel laggy at high tab counts because they:
- Schedule 5–7 separate theme recalculations per tab switch
- Animate color transitions across every
.tabbrowser-tabelement (thousands of nodes) - Re-sample on every revisit instead of caching
- Hook synchronously into
TabSelect, blocking the click
zen-page-tint is built around the opposite constraints:
- One sample per tab switch —
TabSelect+onLocationChangepairs coalesce into a single deferred run - Per-origin LRU cache (3000 entries) — revisits are instant, zero IPC
- Deferred via
requestAnimationFrame+ setTimeout race — tab clicks register before the JS runs, and the safety-netsetTimeoutmeans we don't get stuck whenrAFis throttled - No transitions on per-tab elements — color snaps; sidebar/toolbar still animate smoothly
- Skips
about:/chrome:— keeps Zen's defaults where they belong
From the Sine store (recommended): in Zen, open Sine → Marketplace, search Zen Page Tint, and install. Enable it and restart Zen — updates then land automatically.
Or add it locally from this repo: Sine settings → "Add your own locally from a GitHub repo" → paste https://github.com/caezium/zen-page-tint → enable → restart. A local (unvetted) install also needs sine.allow-unsafe-js = true in about:config for the script portion to load.
No network calls, no telemetry — the script only reads the current page's background color locally via a content-script bridge; no external data leaves your machine.
Everything is configurable from the Sine settings panel for this mod (Zen → Sine → Zen Page Tint → ⚙). Each setting maps 1:1 to an about:config pref, so you can also set them there directly.
Apply live (no restart):
| Pref | Default | Effect |
|---|---|---|
zen.page-tint.mix-amount |
100 |
Tint strength (0–100): the sampled page color is laid as a translucent overlay over Zen's own chrome (including per-workspace gradients). 100 = full page color (the default); 0 = no tint, your untouched Zen theme/gradient shows through; in between veils the gradient with the page color. (It's an opacity overlay, not a color blend, because a workspace gradient can't be reproduced by mixing a single color.) |
zen.page-tint.saturation |
100 |
Vibrancy (0–200) of the tint. 0 = grayscale chrome, 100 = the page's own saturation, 200 = double. |
zen.page-tint.brightness |
100 |
Gain (0–300) on the tint's lightness. 200 = twice as light. Applied before the lightness clamps, so those stay hard bounds. Acts on HSL lightness rather than multiplying RGB channels, so it can't shift hue or clip one channel first. |
zen.page-tint.contrast |
100 |
Pushes lightness (0–300) away from mid-grey — 200 doubles the distance from 50%, 0 flattens to grey. Acts on the single averaged color, so it deepens darks and lifts lights; it can't add local contrast the way a filter over an image would. |
zen.page-tint.ambient-spread |
0 |
Strength (0–100) of the directional gradient — see Directional ambient gradient. 0 = flat tint (default, and the sampler skips the extra work entirely). |
zen.page-tint.min-lightness |
0 |
Lightness floor (0–100): keeps near-black pages from making the chrome muddy. 0 = no floor. |
zen.page-tint.max-lightness |
100 |
Lightness cap (0–100): keeps blinding-white pages from washing the chrome out. 100 = no cap. |
zen.page-tint.disable-hosts |
'' |
Comma-separated host list (same syntax as live-mode-hosts, supports *.example.com) where the tint is turned off — the chrome keeps your Zen theme on those sites. |
zen.page-tint.youtube-ambient |
true |
On YouTube, sample the video's own picture box rather than a centered viewport grab, gated on the player's Ambient mode being on — see YouTube ambient mode. Falls back to normal sampling when Ambient mode is off. |
zen.page-tint.light-average |
false |
Average page samples as light instead of as flat color (decode sRGB to linear, weight vivid cells above gray ones). Physically more accurate and noticeably brighter/more saturated on mixed pages — a mostly-dark page with bright content on it stops reading as dark, which is why it's off by default. The YouTube ambient source always uses this model regardless. |
zen.page-tint.frame-gap |
5 |
Gap (px) between the content area and the window edge in non-compact mode. Compact mode stays flush so Zen's screen-edge hover controls remain reachable. Extremely large values can push the page viewport entirely offscreen. |
zen.page-tint.frame-radius |
14 |
Content corner radius (px). |
Need a restart (read once at load):
| Pref | Default | Effect |
|---|---|---|
zen.page-tint.live-mode |
true |
Master switch for live mode — continuous polling so the chrome tint follows video / animated content. When off, the tint is purely event-driven. |
zen.page-tint.live-mode-rate-ms |
2000 |
The poll interval (0.5 Hz), used verbatim — this is how often the chrome color can change. Floored at 100 ms as a guard against an absurd value. Keep it at or above live-mode-smoothing-ms, or each fade is interrupted by the next sample and the chrome never settles on a color. |
zen.page-tint.live-mode-threshold |
8 |
Minimum per-channel color change (0–255) needed to actually re-tint during live polling. Imperceptible frame-to-frame jitter below this is ignored so the chrome doesn't churn on near-static scenes. 0 = re-tint on any change. This is the sensitivity knob; live-mode-rate-ms is the cadence knob, and they're independent. |
zen.page-tint.live-mode-smoothing-ms |
1000 |
Duration of the CSS fade applied to every tint change (live ticks, event-driven samples, and tab-switch cache hits). |
zen.page-tint.live-mode-always-on |
false |
When false, live polling only runs while a <video> on the page is actually playing — static pages cost nothing. Set true to poll every foregrounded page regardless. |
zen.page-tint.live-mode-hosts |
'' |
Comma-separated host allowlist; matching sites are treated as always-on. The supported workaround for players auto-detect can't see — canvas/WebGL players and cross-origin <iframe> embeds. Matched by hostname, so port-independent (localhost matches localhost:3000). Supports *.example.com. Example: example.com, *.spotify.com, localhost. |
zen.page-tint.debug |
false |
When true, logs diagnostic events to the Browser Console (Cmd-Shift-J / Ctrl-Shift-J). |
The --zpt-frame-shadow drop shadow is still a CSS knob in style.css :root if you want to tune it.
By default the chrome takes one flat color from the page. With zen.page-tint.ambient-spread above 0, it instead takes color from the matching side: the left of the URL bar strip from the left of the page, the top of the sidebar from the top, and so on — so a video with a warm left edge and a cool right edge lights the chrome the same way round.
The data was always there. frame.js reduces the sampled region to a 16×16 grid and the old code averaged all 256 cells into a single color, discarding the spatial layout. Now it also accumulates four directional bands (6 columns/rows in from each edge, leaving the middle to neither so the two sides stay distinguishable) and sends those alongside the base color. It's one extra accumulate per cell inside a loop that was already running, so the cost is negligible — and at spread 0 the frame script is told not to compute them at all.
Rendering is a background-image gradient laid over the existing background-color, with the edge colors carrying the spread strength as alpha so the compositor does the base-to-edge blend. That split matters: the flat base still fades smoothly through the existing colour transition, and the directional layer rides on top without being animated. Edge colors go through the same saturation / brightness / contrast / clamp transform as the base, so tuning any of those moves the whole gradient together instead of letting it drift out of family.
Two deliberate limits:
- Alpha is capped below full. The readable-foreground pick is computed against the base color, so letting an edge fully replace the base at the extremes would break text contrast. Capping keeps the endpoints tethered.
- It's on the sidebar and the URL bar strip only — not the window rim or root. The rim is an inset
box-shadow, which takes solid colors only, and rewriting it as a background would paint over the workspace gradient it exists to preserve. Stacking a gradient on the root would also reintroduce the layering problem described in the tint-strength notes.
Only the two grid-based sample paths can produce a gradient. A page whose color comes from <meta name="theme-color"> or a computed style yields one value with no spatial content, so it tints flat — which is correct, since there'd be nothing to infer a direction from.
This is the analogue of the userstyle's scale(4) plus 3500px blur, done as chrome geometry rather than a GPU blur pass over page content.
YouTube's own "Ambient mode" (cinematic lighting) paints downscaled copies of the current video frame into canvases behind the player and CSS-blurs them into the glow around the video. With zen.page-tint.youtube-ambient on (the default), the sampler reads the video's painted picture box — the same frames that glow is built from — instead of a centered viewport grab, so the browser frame and the player glow track each other.
Two things it fixes:
- Letterbox bars stop dragging the tint dark. A 21:9 video in a 16:9 player puts black bars exactly where the centered read looks, so they get averaged in. The picture box is derived from the video's own
videoWidth/videoHeightagainst its layout box, so the bars are excluded geometrically. The outer 4% is trimmed too — edge rows carry compression ringing and the player rounds its corners. - No page furniture in the sample. Comments, the recommendations column, and the player chrome are all outside the sampled region by construction.
This path is always averaged as light (linear, chroma-weighted) regardless of light-average, because it's modelling light coming off a screen rather than a flat surface color.
Cropping to the video removes the static page furniture that used to damp frame-to-frame variance, so on moving content nearly every poll clears live-mode-threshold. That means the chrome changes at your configured live-mode-rate-ms — every 2 s by default. Raise the rate for a calmer frame, or raise the threshold if you'd rather it only react to real scene changes.
Why not read YouTube's canvas directly? That was the first implementation and it cannot work. YouTube's video is served cross-origin from googlevideo.com without CORS, so the moment a frame is drawn the canvas is origin-tainted — YouTube can display it, but getImageData throws SecurityError. Confirmed in the wild: the canvas reads fine while blank, then dies exactly when playback starts. drawWindow is the privileged compositor read the main pixel path already uses, so tainting doesn't apply to it, and scoping it to the video's picture box gets the same colors with better letterbox handling than the canvas offered.
Turning it on: enable Ambient mode in the YouTube player (gear icon → Ambient mode); it's dark-theme and watch-page only. If you run the Zen Internet transparency mod, its "no ambient mode" toggle removes the element this gates on, so turn that off. Whenever the gate isn't satisfied the normal sample chain runs exactly as before, so there's no failure mode to manage.
Note this reads the video; it doesn't amplify anything. Userstyles that crank #cinematics with a multi-thousand-pixel blur to spill the glow across the whole window are a separate, page-side thing — they cost real GPU and need a transparent-chrome setup to reach the browser frame. This gets the chrome to match at no rendering cost, and works whether or not you run any of that.
- Boost edits on the currently open page require a refresh. When you live-edit a Zen Boost on a page that's already loaded, the chrome tint won't update until you refresh the page (or switch to another tab and back). Boosts apply styling via
CSSStyleSheet.insertRuleand browser-level user-stylesheets, neither of which fires a DOMMutationObserver. Background polling would catch it but at a constant CPU cost that didn't feel worth it — open to revisiting if folks ask. - YouTube in fullscreen video mode drops the
#cinematicselement the ambient path gates on, so it falls back to the centered viewport read — which in fullscreen is the video anyway, just without the letterbox trimming. Outside fullscreen the ambient path handles it. - Live mode doesn't auto-detect video inside cross-origin
<iframe>embeds (the common YouTube/Vimeo/Spotify embed on a third-party page). That<video>lives in a separate browsing context, so its play/pause events never reach the parent document and auto-detect can't see it. The tint would follow it correctly if polling ran — only the trigger is missing. Workaround: add the host tozen.page-tint.live-mode-hoststo force always-on polling there. A site's own pages (e.g. youtube.com itself, where the<video>is same-origin) work without this. drawWindowis flagged non-standard in MDN and may be removed in a future Gecko. If that happens, the meta-tag and computed-style fallbacks still work but Gmail-class accuracy is lost. No drop-in replacement exists today.
tint.uc.js runs in the chrome (browser.xhtml):
- Listens for
TabSelect,onLocationChange(top-level only — iframe loads filtered out),TabClose, and OSprefers-color-schemechanges. - On fire, coalesces via
requestAnimationFrameraced against a 100mssetTimeoutsafety net (rAF can be throttled when the window is occluded), then samples the active browser. - Cache hit → applies
--zen-tab-header-background+--zen-tab-header-foregroundinstantly (no IPC). - Cache miss → loads
frame.jsinto the content process. Frame script samples + observes mutations, and pushes updates viasendAsyncMessage. - Cache is bounded LRU (3000 entries) keyed by
origin + pathname. Cleared on OS color-scheme change so prefers-color-scheme-aware sites re-sample fresh. - Sampled color is adjusted (saturation / lightness clamp) and the foreground (black or white) is picked against the result via Rec 601 luminance for max contrast.
- A transient Zen Glance keeps its parent tab's tint while the parent remains visible behind it. Expanding the Glance into a normal tab hands tint ownership to the child and samples it normally.
frame.js runs in the content process. Sample chain (first match wins):
0. YouTube's video picture box (host-gated to *.youtube.com, gated on #cinematics existing, only when youtube-ambient is on) — a drawWindow read of just the painted video, letterbox bars excluded geometrically. See YouTube ambient mode for why this reads the compositor rather than YouTube's canvas. Falls through to step 1 whenever the gate isn't satisfied or the player is scrolled out of view.
drawWindowpixel of the central 60% of the viewport, downsampled to a 16×16 grid and averaged — ground truth of what's actually painted, weighted to the dominant central tone rather than whatever single element lands dead-center. Picks up Zen Boost overlays, dark-mode toggles, Gmail-class apps where<body>lies about the visible color.<meta name="theme-color">— fallback for the rare case where pixel can't read (pre-paint, fully transparent page). Note this is often the address-bar color a site declares for mobile, not its page bg — e.g. GitHub's meta isrgb(30,35,39)but its actual page bg isrgb(13,17,23)— so we prefer pixel even when meta is present. Media-aware (light/dark variants honored) and normalized to canonicalrgb()via the canvas color parser, so hex/HSL/named all work.body.backgroundColor.html.backgroundColor.- Walk up from
elementFromPointuntil a solid-bg ancestor.
Observers in content:
<html>/<body>attribute mutations — filtered to ~11 theme-relevant attributes (class,style,data-theme,data-bs-theme,data-color-mode, etc.) so noisy pages don't keep waking the sampler.<head>mutations —childList+subtree characterData+ filtered attributes onlink/meta/style. Catches stylesheet swaps and dynamic theme-color changes.load+pageshow— re-sample at +300ms and +2000ms with 500ms dedupe (catches slow apps that bootstrap their theme afterload— Gmail).
style.css lays the page color as a translucent overlay (--zpt-tint, opacity set by the strength pref) across the URL bar, sidebar, titlebar, splitter, tab labels, and the outer window-background rim. Below full strength, Zen's own theme / workspace gradient shows through; the rim uses an inset box-shadow so the gradient survives underneath instead of being painted over.
Measured under a 1,200+ tab session:
| Path | CPU |
|---|---|
| Cache hit (revisit) | ~0.5 ms |
| Cache miss (first visit) | ~10–15 ms |
| Idle | ~0 |
- Developed on Zen 1.20b+ on macOS; also reported working on Linux (the compact-mode and contrast fixes in 1.5.x came from a CachyOS user). Windows should work too — selectors target Zen's stable chrome IDs — but it's unverified there. Reports welcome.
- Sine required (install via the Sine store, or locally from this repo).
MIT.
Issues and PRs welcome. Two guardrails:
- Keep per-tab-switch work bounded — cache hits should stay zero-IPC, and frame-script mutation handling should stay filtered + debounced.
- If you add a new sample-chain step, add a one-liner explaining the case it catches that the existing steps miss.

