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.
: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>:
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:
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:
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:
Your own choice.applyAdwaitaAccent(name) wins over everything below, and
clearAdwaitaAccent() hands the page back.
A server handoff. Two tags in <head>, read on import and whenever they change:
HTML
<metaname="adw-accent"content="purple">
<metaname="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).
The browser’s AccentColor, once you call applySystemAccent(), or when the tag says
content="system".
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.
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';
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 clientofclients)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.
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
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
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.