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.
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 (500 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
In Zen → Sine settings → "Add your own locally from a GitHub repo", paste:
https://github.com/caezium/zen-page-tint
Enable in Sine, restart Zen.
Until this is on the Sine store you'll need
sine.allow-unsafe-js = trueinabout:configfor the script portion to load. (That's Sine's safety gate — the script only reads the current page's background color via a content-script bridge: no network calls, no external data.)
In style.css :root:
--zpt-frame-gap: 5px; /* gap between content area and window edge */
--zpt-frame-radius: 14px; /* content corner radius */
--zpt-frame-shadow: ...; /* drop shadow on content frame */In about:config (pref changes take effect on the next Zen restart):
| Pref | Default | Effect |
|---|---|---|
zen.page-tint.debug |
false |
When true, logs diagnostic events to the Browser Console (Cmd-Shift-J / Ctrl-Shift-J). Toggle live. |
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 idle poll interval (0.5 Hz). Sampling is adaptive: it speeds up to rate ÷ 4 (floored at 250 ms / 4 Hz) while the color is actively changing, then backs off to this rate once it's stable. So scene changes are followed responsively while static frames stay cheap. |
zen.page-tint.live-mode-threshold |
8 |
Minimum per-channel color change (0–255) needed to actually re-tint the chrome during live polling. Imperceptible frame-to-frame jitter below this is ignored, so the chrome doesn't churn (and the adaptive rate backs off) on near-static scenes. 0 = re-tint on any change. |
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. |
- 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 samples the current video frame's center pixel, which is whatever's on screen at that moment. Outside fullscreen it samples the player chrome and works correctly.
- 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 (500 entries) keyed by
origin + pathname. Cleared on OS color-scheme change so prefers-color-scheme-aware sites re-sample fresh. - Foreground color picked via Rec 601 luminance — black or white for max contrast.
frame.js runs in the content process. Sample chain (first match wins):
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 applies the two CSS variables to URL bar, sidebar, titlebar, splitter, tab labels, and the outer window-background pseudo-elements (so the rim tints too — no accent-color bleed from Zen's theme).
Measured under a 1,200+ tab session:
| Path | CPU |
|---|---|
| Cache hit (revisit) | ~0.5 ms |
| Cache miss (first visit) | ~10–15 ms |
| Idle | ~0 |
- Tested on Zen 1.20b+ on macOS. Should work on Linux and Windows — selectors target Zen's stable chrome IDs — but I haven't verified there yet. Reports welcome.
- Sine required (currently the only install path).
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.