Skip to content

Theming

Every colour, radius, spacing step and font in @gjsify/adwaita-web comes from a CSS custom property. Set one above the widgets and everything below picks it up. That is the whole theming API, and it is the part that keeps working across upgrades.

Put the tokens on a container that wraps your widgets. This is the route that behaves the same in light and dark mode: the only rules that give a token a second, scheme-dependent value sit on :root and on .theme-dark / .theme-light, so a container below them keeps your values in both.

CSS
.app {
--accent-bg-color: #9141ac;
--accent-fg-color: #ffffff;
--window-bg-color: #f6f5f4;
--card-radius: 16px;
}
HTML
<div class="app">
<adw-preferences-group title="Purple everything">
<adw-switch-row title="Wi-Fi" active></adw-switch-row>
</adw-preferences-group>
</div>

:root works too, with one thing to watch: most of the colour tokens get a second value in dark mode, and that dark rule outranks a plain :root declaration.

CSS
:root {
--accent-bg-color: #9141ac;
--font-family: 'Inter', sans-serif;
--card-radius: 16px;
}
Which tokens are safe on `:root`

Tokens that mean the same thing in both schemes: the accent fill, the radii, the spacing scale, the fonts and the opacity tokens. To move a scheme-dependent colour globally, give it a dark counterpart as well, or move the override into a container as above.

Dark mode applies on its own under prefers-color-scheme: dark. To pin a scheme for part of the page, put .theme-dark or .theme-light on any element, not only on <html>:

HTML
<div class="theme-dark">
<adw-preferences-group title="Always dark">
<adw-switch-row title="Wi-Fi" active></adw-switch-row>
</adw-preferences-group>
</div>
Overriding your own theme

Those two classes re-declare the full palette on the element they sit on, so if one element carries both your theme and a .theme-* class, write the selector so yours wins:

CSS
.app.theme-dark {
--window-bg-color: #201d18;
}

Adwaita has nine named accents: blue (the default), teal, green, yellow, orange, red, pink, purple and slate. Applying one sets both accent properties for you, picking the standalone variant that matches the target’s current scheme:

TypeScript
import { applyAdwaitaAccent, clearAdwaitaAccent } from '@gjsify/adwaita-web';
const panel = document.getElementById('panel')!; // the element you want scoped
applyAdwaitaAccent('purple'); // on <html>
applyAdwaitaAccent('teal', { target: panel }); // on one element
applyAdwaitaAccent('teal', { target: panel, dark: true });
clearAdwaitaAccent(); // back to the stylesheet's own value

A native Adwaita window follows the accent the user picked in Settings. A web page can do the same, from whichever of these sources it has, highest first:

  1. Your own choice. applyAdwaitaAccent(name) wins over everything below, and clearAdwaitaAccent() hands the page back.

  2. A server handoff. Two tags in <head>, read on import and whenever they change:

    HTML
    <meta name="adw-accent" content="purple">
    <meta name="adw-color-scheme" content="dark">

    For live updates, send the same values as JSON over a channel your app already has and pass them to applyDesktopAppearance(json).

  3. The browser’s AccentColor, once you call applySystemAccent(), or when the tag says content="system".

  4. Adwaita blue, the stylesheet’s own value.

Every source is snapped to the nearest of the nine accents, which is what libadwaita does with the system colour, so the page matches a native window on the same desktop.

TypeScript
import { adwaitaAccentSource, applyDesktopAppearance, applySystemAccent } from '@gjsify/adwaita-web';
applySystemAccent(); // follow AccentColor where the browser resolves it
new EventSource('/appearance').onmessage = (event) => applyDesktopAppearance(JSON.parse(event.data));
adwaitaAccentSource(); // 'app' | 'handoff' | 'system' | 'default'
Prefer the handoff when you can

Browser support for AccentColor is uneven, and a browser may report a fixed default rather than the user’s accent. On a GNOME desktop set to purple, the headless Firefox and Chromium that Playwright ships both reported a blue. A page cannot tell such a default from a real accent.

A gjsify server can read the desktop without opening a window, on Linux through the XDG Settings portal (GNOME, KDE, Flatpak) with a GSettings fallback on GNOME, and on Windows and macOS from the system settings:

TypeScript
import { createServer, type ServerResponse } from 'node:http';
import { type DesktopAppearance, renderAppearanceMeta, watchDesktopAppearance } from '@gjsify/adwaita-app/appearance';
let appearance: DesktopAppearance = {};
const clients = new Set<ServerResponse>();
const server = createServer((req, res) => {
if (req.url === '/appearance') {
res.writeHead(200, { 'content-type': 'text/event-stream' });
res.write(`data: ${JSON.stringify(appearance)}\n\n`);
clients.add(res);
req.on('close', () => clients.delete(res));
return;
}
res.writeHead(200, { 'content-type': 'text/html' });
res.end(`<!doctype html><head>${renderAppearanceMeta(appearance)}</head><body>…</body>`);
});
server.listen(3000); // before the first await, see below
// Reports the first read, then every change. Keep `stop`: it keeps the watch alive.
const stop = watchDesktopAppearance((next) => {
appearance = next;
for (const client of clients) client.write(`data: ${JSON.stringify(next)}\n\n`);
});
Two things before you ship that server

Start listening before your module awaits anything. On GJS today, a server started after a top-level await exits as soon as the module finishes loading. The example is a complete server.

A field the desktop does not report is left out, so the page falls through to its next source. The Windows and macOS readers have not been verified on those systems yet; the package README lists what is measured where.

The values below are the light defaults, read out of the stylesheet that declares them when this page is built.

Window

TokenLight default
--window-bg-color#fafafb
--window-fg-colorrgba(0, 0, 6, 0.8)

Views

TokenLight default
--view-bg-color#ffffff
--view-fg-colorrgba(0, 0, 6, 0.8)

Header bar

TokenLight default
--headerbar-bg-color#ffffff
--headerbar-fg-colorrgba(0, 0, 6, 0.8)
--headerbar-shade-colorrgba(0, 0, 6, 0.12)

Hairline separators (header underline, sidebar/pane dividers)

TokenLight default
--separator-colorrgba(0, 0, 6, 0.1)

The GENERIC shade the whole `*-shade-color` family below specialises (`_colors.scss:79,141`)

TokenLight default
--shade-colorrgba(0, 0, 6, 0.07)
TokenLight default
--sidebar-bg-color#ebebed
--sidebar-fg-colorrgba(0, 0, 6, 0.8)
--sidebar-backdrop-color#f2f2f4
--sidebar-shade-colorrgba(0, 0, 6, 0.07)
--sidebar-border-colorrgba(0, 0, 6, 0.07)

The MIDDLE pane of a three-pane setup

TokenLight default
--secondary-sidebar-bg-color#f3f3f5
--secondary-sidebar-fg-colorrgba(0, 0, 6, 0.8)
--secondary-sidebar-backdrop-color#f6f6fa
--secondary-sidebar-shade-colorrgba(0, 0, 6, 0.07)
--secondary-sidebar-border-colorrgba(0, 0, 6, 0.07)

Cards / boxed lists

TokenLight default
--card-bg-color#ffffff
--card-fg-colorrgba(0, 0, 6, 0.8)
--card-shade-colorrgba(0, 0, 6, 0.07)

Accent

TokenLight default
--accent-bg-color#3584e4
--accent-fg-color#ffffff
--accent-color#1c71d8

Popover (menus, dropdowns) — an elevated surface above the window

TokenLight default
--popover-bg-color#ffffff
--popover-fg-colorrgba(0, 0, 6, 0.8)
--popover-shade-colorrgba(0, 0, 6, 0.07)

Dialog scrim — the dimming wash behind a presented dialog (Adw dims with --shade-color at double alpha; this is the equivalent neutral wash)

TokenLight default
--dialog-scrim-colorrgba(0, 0, 6, 0.32)

Buttons

TokenLight default
--button-bg-colorrgba(0, 0, 6, 0.08)
--button-hover-colorrgba(0, 0, 6, 0.13)
--button-active-colorrgba(0, 0, 6, 0.19)

Entries

TokenLight default
--entry-bg-colorrgba(0, 0, 6, 0.1)
--entry-border-colorrgba(0, 0, 6, 0.18)
--entry-border-hover-colorrgba(0, 0, 6, 0.28)

Destructive action

TokenLight default
--destructive-bg-color#e01b24
--destructive-fg-color#ffffff
--destructive-coloroklab(from var(--destructive-bg-color) min(l, 0.5) a b)

Semantic states

TokenLight default
--success-bg-color#2ec27e
--success-fg-color#ffffff
--success-coloroklab(from var(--success-bg-color) min(l, 0.5) a b)
--warning-bg-color#e5a50a
--warning-fg-colorrgba(0, 0, 0, 0.8)
--warning-coloroklab(from var(--warning-bg-color) min(l, 0.5) a b)
--error-bg-color#e01b24
--error-fg-color#ffffff
--error-coloroklab(from var(--error-bg-color) min(l, 0.5) a b)

Switch

TokenLight default
--switch-off-bgrgba(0, 0, 0, 0.2)
--switch-knob-bg#ffffff

Layout radii

TokenLight default
--window-radius15px
--card-radius12px
--button-radius9px

Spacing scale

TokenLight default
--spacing-xs6px
--spacing-s9px
--spacing-m12px
--spacing-l18px
--spacing-xl24px

Typography — GNOME default: Adwaita Sans 11

TokenLight default
--font-family'Adwaita Sans', 'Cantarell', 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif
--font-size-base11pt
--font-size-small9pt
--font-size-heading12pt
--dim-opacity0.55

Borders and disabled dimming, as libadwaita's own two-step (_colors.scss:263, 317-329): an OPACITY token the high-contrast query moves, and a colour DERIVED from it against `currentColor` — so a border follows the text colour of the surface it is drawn on instead of being a fixed grey

TokenLight default
--border-opacity15%
--border-colorcolor-mix(in srgb, currentColor var(--border-opacity), transparent)
--disabled-opacity0.5

Document + monospace families, behind `.document` / `.monospace` (_labels.scss:75-84)

TokenLight default
--document-font-familyvar(--font-family)
--document-font-sizevar(--font-size-base)
--monospace-font-family'Adwaita Mono', ui-monospace, 'SF Mono', 'Cascadia Code', 'Consolas', 'Menlo', monospace
--monospace-font-sizevar(--font-size-base)

69 tokens in 21 groups.

Three of them move on their own: under prefers-contrast: more the stylesheet changes --border-opacity, --dim-opacity and --disabled-opacity. Every colour derived from those follows, so --border-color sharpens without a query of your own.

You can also write plain CSS. The components render in the light DOM, so a higher-specificity rule of yours wins the way it normally would. Tokens are the route that survives upgrades; a rule targeting internal markup is not.

Building a skin from the source

The SCSS partials ship with the package at @gjsify/adwaita-web/scss/…, with @gjsify/adwaita-web/scss as the entry point.

How It Works explains what the light-DOM boundary does and does not guarantee.

On the desktop, the accent is not yours to set through AdwStyleManager

Section titled “On the desktop, the accent is not yours to set through AdwStyleManager”

A native GTK app takes its accent from the user’s GNOME setting, and AdwStyleManager:accent-color is the wrong handle on every runtime: it is an enum (AdwAccentColor — nine named accents), so a token colour like #9141ac has no representation in it. It is read-only as well — libadwaita installs the property readable-only, and every closure measured agrees — but the enum is the reason that holds whatever a flag says.

How a native app overrides the palette anyway

By the same mechanism this page uses on the web: redefining --accent-bg-color and its siblings on :root, in a Gtk.CssProvider above libadwaita’s own. What is different on the desktop is the provider priority — see Styling on GTK for the three measurements and the registry that wraps them.

Whether you should override the palette at all is a separate question, and the default answer is no: someone who picked an accent in Settings expects your app to use it. If your app renders on both, set the token for the web and leave the desktop alone unless the look is the point.