Theming
Theme modes, the built-in palettes, and how to give Glint your product's own colors.
A Glint component never names a color. It reads a semantic role from Tokens
— color-background, color-primary, color-destructive — and the theme
decides what each role is worth right now. Two runtime switches move all of
them at once: Theme.mode, which is dark or light, and Theme.palette, which
is the visual identity underneath.
The switcher in this site’s header drives exactly those two properties. Every preview below is running under the mode and palette you have selected there, recompiled when you change them — which is also what your app gets, reactively and without recreating anything.
How a color reaches a component
Four levels, each with one job:
Theme.paletteholds the activePaletteSpec— the identity colors: a neutral scale and four accent pairs.Paletteresolves the primitives (neutral-900,red-400) from that spec, and adds the alpha overlays (scrim, hairline, glass), which are constants: overlay physics, the same under every palette.Tokensmaps those primitives onto semantic roles, choosing per mode —color-backgroundisneutral-950in dark andneutral-50in light.- Components read
Tokens, and know neither the mode nor the palette.
That is why one assignment restyles an entire window. The swatches below are
Tokens values, painted as themselves:
import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 560px; height: 140px; background: Tokens.color-background;
HorizontalLayout { padding: 16px; spacing: 8px;
for swatch in [ { name: "background", color: Tokens.color-background }, { name: "card", color: Tokens.color-card }, { name: "primary", color: Tokens.color-primary }, { name: "muted", color: Tokens.color-muted }, { name: "destructive", color: Tokens.color-destructive }, { name: "affirm", color: Tokens.color-affirm }, { name: "warning", color: Tokens.color-warning }, { name: "info", color: Tokens.color-info }, ]: VerticalLayout { spacing: 6px;
Rectangle { height: 56px; background: swatch.color; border-radius: Tokens.radius-md; border-width: 1px; border-color: Tokens.color-border-hairline-strong; }
Text { text: swatch.name; color: Tokens.color-muted-foreground; font-size: Tokens.typography-label-size; horizontal-alignment: center; } } }}Write against the role, not the value: a card surface is
Tokens.color-card, never Palette.neutral-900. The role survives a palette
swap and a mode flip; the primitive is what one of them happens to resolve to.
Theme modes
Theme.mode is ThemeMode.dark by default, and ThemeMode.light is the other
one. It is a global property, so assigning it anywhere retints every component
in the window:
import { Theme, ThemeMode, Tokens } from "@glint/theme/tokens.slint";
export component ThemedView inherits Rectangle { background: Tokens.color-background;
Text { text: "Follows the mode"; color: Tokens.color-foreground; }}The assignment itself goes anywhere a statement does — a menu item, a settings
screen, an init handler:
Theme.mode = ThemeMode.light;Glint ships no theme control of its own — a switch belongs to your product’s chrome, not to a component library. Yours is that assignment in a handler:
Button { text: Theme.mode == ThemeMode.dark ? "Light mode" : "Dark mode"; clicked => { Theme.mode = Theme.mode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark; }}Intent roles stay semantic across both: color-destructive is a red that
works on the mode’s own background, not a fixed hex that only reads on one of
them. A screen that has to branch on the mode is rare, and usually a sign that
a role is missing rather than that a condition is needed.
Following the operating system
Theme.follow-system is the opt-in that syncs the mode with the platform’s
color scheme, with no Rust at all:
import { Theme } from "@glint/theme/tokens.slint";
export component AppWindow inherits Window { init => { Theme.follow-system = true; }}With it on, Theme.mode initializes from the system scheme and keeps tracking
it while the user changes it. A platform that reports no scheme falls back to
dark, the same default as without it.
It comes with one rule, and it is Slint’s own binding semantics rather than a
Glint invention: assigning Theme.mode replaces the binding. From that
point the mode is manual for the rest of the run, even if follow-system is
still true. So pick one strategy per app — follow the system, or offer your own
toggle. To stop following while keeping what is on screen, assign
Theme.mode; merely turning follow-system off returns the mode to dark.
From Rust
Re-export the globals your program touches from your top-level .slint file:
import { Theme, ThemeMode } from "@glint/theme/tokens.slint";export { Theme, ThemeMode }They then appear on the generated type like any other global:
ui.global::<Theme>().set_mode(ThemeMode::Light);Built-in palettes
A palette is a value: a PaletteSpec holding the neutral scale
(neutral-50 … neutral-950) and the accent pairs (red, green, amber and
blue, each at 400 and 600). Glint ships four, and Palettes.neutral is the
default:
import { Theme, Palettes } from "@glint/theme/tokens.slint";
Theme.palette = Palettes.neutral; // the default — cold gray, tuned for OLED blackTheme.palette = Palettes.slate; // cool gray, blue-tintedTheme.palette = Palettes.stone; // warm grayTheme.palette = Palettes.zinc; // neutral gray, slightly cooler than stoneSelection is reactive and applies to the whole tree. The Palette switch in the header is doing this to the components below right now:
import { Badge, BadgeVariant } from "@glint/components/badge.slint";import { Button, ButtonVariant } from "@glint/components/button.slint";import { Card, CardDescription, CardFooter } from "@glint/components/card.slint";import { Tokens } from "@glint/theme/tokens.slint";
export component Demo inherits Window { width: 520px; height: 200px; background: Tokens.color-background;
VerticalLayout { alignment: center; padding: Tokens.spacing-xl;
Card { VerticalLayout { padding: parent.padding-l; spacing: parent.gap-l;
HorizontalLayout { spacing: Tokens.spacing-sm; alignment: start;
Badge { text: "Stable"; } Badge { text: "Beta"; variant: BadgeVariant.secondary; } Badge { text: "Deprecated"; variant: BadgeVariant.destructive; } }
CardDescription { text: "The neutral scale carries the surfaces; the accents carry intent."; }
CardFooter { Button { text: "Cancel"; variant: ButtonVariant.outline; } Button { text: "Save"; } } } } }}From Rust, a built-in palette is read off Palettes and assigned to Theme —
so both globals have to be re-exported, not just Theme:
import { Theme, Palettes } from "@glint/theme/tokens.slint";export { Theme, Palettes }ui.global::<Theme>().set_palette(ui.global::<Palettes>().get_stone());Custom palettes
Build a PaletteSpec of your own and assign it. It works in both modes for
free, because the mode mapping lives in Tokens, one level above the value you
are replacing:
import { Theme, PaletteSpec } from "@glint/theme/tokens.slint";
global BrandPalette { out property <PaletteSpec> spec: { neutral-50: #f9fafb, neutral-100: #f3f4f6, neutral-200: #e5e7eb, neutral-300: #d1d5db, neutral-400: #9ca3af, neutral-500: #6b7280, neutral-600: #4b5563, neutral-700: #374151, neutral-800: #1f2937, neutral-900: #111827, neutral-950: #030712, red-400: #f87171, red-600: #dc2626, green-400: #4ade80, green-600: #16a34a, amber-400: #fbbf24, amber-600: #d97706, blue-400: #60a5fa, blue-600: #2563eb, };}Then assign it, anywhere, once:
Theme.palette = BrandPalette.spec;Set every color. A field left out of a Slint struct literal is transparent, not inherited — a spec with eighteen of the nineteen has one invisible role rather than a sensible fallback.
Two rules of thumb for choosing the values:
- The neutral scale runs light to dark:
neutral-50is the lightest andneutral-950the darkest, in both modes. Dark mode takes its backgrounds from the dark end and its text from the light end; light mode does the reverse. Inverting the scale inverts every surface in the app. - Accents come in pairs on purpose. The
600is used in light mode and the400in dark, so each has to stay legible against its own mode’s background.
A PaletteSpec changes what a color is. Changing which role a color fills
— making color-primary read from the blue accent instead of the neutral
scale, say — is an edit to theme/tokens.slint, which means forking Glint
rather than configuring it.
The alpha overlays are deliberately outside the spec: scrims, hairlines and glass surfaces are the same translucent blacks and whites under every palette, because they are physics rather than identity.
The token families
theme/tokens.slint is the whole vocabulary, and it goes well past color:
surfaces and intent colors, overlay and focus-ring colors, radii, motion
durations and easings, spacing, control densities, and typography sizes and
weights. Use the token whose type matches the property you are setting —
length for dimensions, duration for animations — rather than converting
one into a number.
Typography sizes are rem-based, resolved against the system font size Slint
reports in Window.default-font-size. Setting a font-size from
Tokens.typography-body-size therefore follows the reader’s own font-size
preference, which a hard-coded 14px does not.
The focus ring
One token family is a component as well as a vocabulary. Every Glint control
draws the same keyboard-focus ring, and it is painted entirely from tokens:
focus-ring-width and focus-ring-offset for the outline and the gap it
leaves, color-ring-muted for the color, and the host’s own corner radius plus
that offset for the curve. Retune those and every control in the app follows,
the way a palette swap moves every surface.
The rule the ring obeys is the web’s :focus-visible contract: it appears when
the keyboard brought the focus here, and stays hidden when a pointer did. A user
who clicks a button and one who tabs to it are asking different questions, and
only the second one needs to be told where the keyboard is.
A control of your own gets the same ring from the same tokens. It is three
elements: a FocusVisibleScope that knows how the focus arrived, a TouchArea
that hands it pointer focus without lighting the ring, and a FocusRing gated
on the scope’s focus-visible. Tab into the preview below and press
Space:
import { FocusRing, FocusVisibleScope } from "@glint/components/focus-ring.slint";import { Tokens } from "@glint/theme/tokens.slint";
component Tile inherits Rectangle { in property <string> label; callback activated();
accessible-role: button; accessible-label: root.label; accessible-action-default => { root.activated(); }
forward-focus: keys;
width: 148px; height: 44px; border-radius: Tokens.radius-md; border-width: 1px; border-color: Tokens.color-border-hairline-strong; background: press.pressed ? Tokens.color-accent : Tokens.color-card;
Text { text: root.label; color: Tokens.color-foreground; font-size: Tokens.typography-body-size; }
press := TouchArea { // Pointer focus, without the ring: the click already said where the // user is looking. clicked => { keys.focus-from-pointer(); root.activated(); } }
keys := FocusVisibleScope { key-pressed(event) => { if (event.text == " " || event.text == "\n") { root.activated(); return EventResult.accept; } return EventResult.reject; } }
// The ring is the last child, so it draws over the surface, and it takes // the host's box: a component's root cannot read `parent` in Slint. if keys.focus-visible: FocusRing { host-width: parent.width; host-height: parent.height; }}
export component Demo inherits Window { width: 480px; height: 140px; background: Tokens.color-background;
in-out property <int> presses: 0;
VerticalLayout { alignment: center; spacing: 12px; padding: 16px;
HorizontalLayout { alignment: center; spacing: 12px;
Tile { label: "Tab to me"; activated => { root.presses += 1; } }
Tile { label: "Then to me"; activated => { root.presses += 1; } } }
Text { text: root.presses == 0 ? "Tab in, then press Space" : "Activated " + root.presses + "×"; color: Tokens.color-muted-foreground; font-size: Tokens.typography-label-size; horizontal-alignment: center; } }}FocusRing
The outline itself. It draws around the box it is given, expanded by offset,
so the host passes its own width and height in — a component’s root cannot read
parent in Slint. Every default is a token; override radius where the host’s
corners are not radius-md, and ring-color where a control needs the louder
color-ring.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
host-width | in length | no default | The host element's own box; the ring is drawn around it expanded by offset. |
host-height | in length | no default | |
offset | in length | Tokens.focus-ring-offset | |
ring-width | in length | Tokens.focus-ring-width | |
radius | in length | Tokens.radius-md + root.offset | |
ring-color | in color | Tokens.color-ring-muted |
FocusVisibleScope
The FocusScope that decides when the ring is allowed. focus-visible is true
only while this scope holds the keyboard and arrived by tab navigation, which
is what makes the ring keyboard-only. It leaves pointer events alone, so it
never steals the first click from the host’s TouchArea; the host hands it
focus with focus-from-pointer() instead, and a control that steps its focus
with an arrow key uses focus-from-keyboard() — a plain focus() reads as
programmatic and would drop the ring mid-walk.
focus-held is the same question asked across a popup: it stays true while a
surface this control opened has borrowed the window’s focus, which has-focus
cannot say.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
focus-visible | out bool | no default | True while this scope is focused and that focus is keyboard-driven — gate the host's FocusRing on this. enabled is part of it, and it has to be: a control that goes dead while it holds the keyboard — a pager step once the last page is reached, a calendar chevron at the end of its range, a form disabling itself on submit — keeps has-focus, because Slint's disabled FocusScope swallows the focus-out that would have cleared it. Every host sets enabled: !root.disabled here, so asking the scope is what gates all of them; Button, Calendar and Pagination each found that gate on their own first, which is how it came to be missing from the other six. |
focus-held | out bool | no default | True while the keyboard belongs to this scope, *including* while a popup has borrowed the window's focus. focus-visible cannot answer that: it reads has-focus, which a popup takes, so a surface opened *because* this control was focused sees the reason for its own existence vanish the moment it appears. A hover surface gates on this instead. |
Functions
| Function | Description |
|---|---|
focus-from-pointer() | Focus this scope the way a pointer interaction would: the control becomes the keyboard target but the ring stays hidden. |
focus-from-keyboard() | The other half of that pair, and the one a keyboard walk needs: an arrow that steps from one member of a set to the next moves the focus with focus(), which Slint reports as programmatic — indistinguishable from a host parking the keyboard, so the branch above would drop the ring mid-walk and leave a keyboard user with no indicator anywhere. The flag is restated *after* the move, because that is when the reason has been handled and the ring is being claimed on purpose. |
PressLayer
When the whole surface is the control — a row, a card, a tile — those three
elements plus the accessible button node are one element instead. Drop a
PressLayer in as a child, give it a label, and handle activated(): the
pointer, Space, Enter and the accessible default action
all arrive there, so the paths cannot drift apart. It publishes pressed and
hovered because the row’s own paint depends on them.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
label | in string | no default | What assistive technology calls the action. |
liveness | in AccessibleLiveness | AccessibleLiveness.off | How loudly the row announces itself while something is in flight. |
ring-radius | in length | Tokens.radius-md + Tokens.focus-ring-offset | The ring's corner radius, when the row's own corners are not the default. |
pressed | out bool | no default | |
hovered | out bool | no default |
Callbacks
| Callback | Description |
|---|---|
activated() | Fired by the pointer, by Space and Enter, and by the accessible default action — the one place an activation lands, so the three cannot drift. |
FocusSentinel
A zero-size marker that notices the focus reaching it — the boundary an overlay
wraps its Tab cycle at. It draws nothing and never takes a click, so traversal
is the only way to reach it, and reached() is where the owner sends the focus
on to wherever the cycle continues. Glint’s own overlays are built from it; a
custom overlay root that has to trap the keyboard needs one at each end.
Callbacks
| Callback | Description |
|---|---|
reached() | Fired when the focus reaches this marker, and not when it leaves. |