Skip to content

Styling & Design Language

GTK styling is not web CSS, and the style prop is not a CSS reimplementation. Two props, two jobs: style covers geometry, cssClasses reaches each platform’s design language.

style covers layout and geometry only, never color-as-theme and never flex/grid/position. Layout comes from container widgets (<box>, <grid>), not from style. The valid keys, generated from schema/widgets.json:

Key Shape Compiles to
background color GTK CSS background-color
color color GTK CSS color
font object (fontSize, fontWeight, fontFamily) GTK CSS, nested
padding spacing GTK CSS padding
margin spacing GTK widget property (gtk_widget_set_margin_*), not CSS
hexpand / vexpand bool GTK widget property
halign / valign enum (fill/start/end/center) GTK widget property
border object (borderWidth, borderColor, borderRadius) GTK CSS, nested

margin differs from padding because GTK margins genuinely are widget properties rather than CSS. The schema encodes that distinction so codegen emits the right call on each backend. Unknown or web-only keys like display and justifyContent are rejected at the React renderer with a Levenshtein fix-it message, and rejected again host-side, so a bad key fails loudly at commit time.

The generated docs/styling.md carries the always-current key list. This page summarizes it.

cssClasses: reaching each platform’s design language

Section titled “cssClasses: reaching each platform’s design language”

cssClasses?: string[] is a set of named classes borrowed from libadwaita’s vocabulary. They map onto real per-platform mechanisms rather than CSS you write yourself.

On Linux the class list is reconciled as a set on every update: each allowlisted class is added when requested and removed when no longer requested, so classes never accumulate across renders. Container-scoped classes (boxed-list, boxed-list-separate, menu, inline) style nothing when applied to a widget type libadwaita does not target with them. The host prints a one-time ND_WARN naming the class and widget type; reach for the structural widgets (<sourcelist>, <sourcetree>, <settingsgroup>) when you want that chrome.

Three classes libadwaita scopes to other widget types are carried by framework base CSS so they mean the same thing on both backends: pill on a <label> is a capsule count badge (libadwaita treats it as a button size class), activatable on a <box> is row hover feedback (libadwaita scopes it to row), and navigation-sidebar on a <box> gives its <button> children libadwaita’s own sidebar-row metrics and states (libadwaita scopes those to row, which cannot exist inside a box).

On macOS a semantic subset maps onto real AppKit control properties, using dynamic system colors throughout so dark mode keeps working:

Class AppKit mapping
suggested-action NSButton.bezelColor = .controlAccentColor, default key equivalent
destructive-action NSButton.bezelColor = .systemRed, hasDestructiveAction = true
flat NSButton.isBordered = false
pill NSButton.borderShape = .capsule; on a <label>, a capsule quaternary-fill count badge
activatable (on a <box>) native hover feedback: a quaternary-fill highlight at the concentric radius
title-1 through title-4 .preferredFont(forTextStyle:) with .largeTitle, .title1, .title2, .title3
heading, body, caption, caption-heading matching preferredFont text styles
dimmed .textColor = .secondaryLabelColor
monospace, numeric monospaced and monospaced-digit system font
toolbar (on a <box>) NSVisualEffectView .headerView backing plus a 1 pt bottom hairline
boxed-list (on a <box>) native grouped NSBox card with inset hairline dividers
navigation-sidebar (on a <box>) a .sourceList NSTableView backing the box, when its children are row-shaped

navigation-sidebar on a box of flat <button> rows puts a real source-list table behind the box on macOS, so accent-when-key selection, row metrics, and row insets come from AppKit instead of your styling. The takeover is gated on the children being row-shaped: the table covers the whole box, and a composite row (button plus caption plus badge) would lose everything that is not the button. nd-native-sidebar on the same box skips that gate and takes over unconditionally.

The class is portable. On GTK the same box gets libadwaita’s sidebar-row metrics and states through framework base CSS. Both backends read suggested-action on a row as the selection signal and neither paints it as an accent call to action inside a sidebar, so one tree gives you a native sidebar on each platform with no per-row padding. <sourcelist> and <sourcetree> are still the better choice when the rows are data rather than a fixed handful of destinations.

The remaining structural classes (card, osd) are ignored on macOS, where that chrome comes from the <splitview> and <headerbar> widgets themselves.

Beyond classes, three Button props carry state the platform renders natively: prominent maps to the accent treatment (suggested-action on GTK; an accent bezel, or a .prominent toolbar item once the button is promoted into the window toolbar, on macOS), badge renders a count next to the control (NSItemBadge on a promoted toolbar item; a capsule suffix label on GTK), and size picks the control metrics (NSControl.controlSize; compact/large CSS metrics on GTK). Window-level density="compact" opts the whole window into compact control metrics (prefersCompactControlSizeMetrics on macOS, a tightened CSS block on GTK).

The Linux host runs as an AdwApplication, so AdwStyleManager tracks the system light and dark preference from the first frame. Unstyled widgets and cssClasses follow it with no app code. The macOS mappings above use dynamic system colors for the same reason. A hardcoded style.color or style.background is an explicit override and does not adapt.

examples/notes/main.tsx holds to this as a hard rule: no color literals anywhere except one pinned-row accent border chosen to read on both themes.

For a live accent color (a status dot, a chart series), read system.getAppearance() or system.onAppearanceChange(), which return { appearance: "light" | "dark", accentColor: "#rrggbb" }: the AdwStyleManager accent on Linux, NSColor.controlAccentColor on macOS.

For bespoke layout (<box spacing>, style.padding), @nativedesktop/react exports the platform’s design-language scale instead of magic numbers:

import { Spacing, ContentMargin, ContentWidth } from "@nativedesktop/react";
<box spacing={Spacing.sm} style={{ padding: ContentMargin }} />;

Spacing is { xs, sm, md, lg, xl }: 3/6/12/18/24 on the GTK backend (GNOME’s multiples of 6), 4/8/12/20/24 on AppKit. ContentMargin is the standard window-edge margin, 12 on GTK and 20 on AppKit. All three are keyed on Platform.backend, not Platform.os, so the GTK backend running on macOS via Quartz still lays out GNOME’s numbers. See Which backend is drawing. They resolve after render()’s handshake (Spacing’s fields are live getters, ContentMargin and ContentWidth plain bindings re-assigned once the backend is known) and fall back to the OS convention before that. The structural widgets (<settingsgroup>, <row>, <clamp>, <sourcelist>) carry native metrics themselves.

ContentWidth is the platform’s reading measure for settings-shaped content — 600 on GTK (AdwClamp’s own default), 720 on AppKit. Give it to a <clamp> so the whole column shares one width:

<scrollview>
<clamp maximumSize={ContentWidth}>
<box orientation="vertical" style={{ padding: ContentMargin }}>
<settingsgroup title="Code Editor"></settingsgroup>
<codeeditor text={code} language="typescript" />
</box>
</clamp>
</scrollview>

The cap belongs to the column, not to any one widget. On AppKit <settingsgroup> is a SwiftUI grouped Form, and that style caps and centers its own card inside whatever width it is handed — a <codeeditor> or <table> beside it has no such cap, so in a wide pane the two disagree, and nothing you can pass the group turns its cap off. Clamping the column below the cap settles it: the Form stops capping, its card fills the column, and its siblings line up with it. GTK behaves the same way natively, since AdwPreferencesGroup fills its AdwClamp. Leave canvases (a chart grid, a table, a board) outside the clamp so they still get the whole pane.