# 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:

1. `Theme.palette` holds the active `PaletteSpec` — the identity colors: a neutral scale and four accent pairs.
2. `Palette` resolves 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.
3. `Tokens` maps those primitives onto semantic roles, choosing per mode — `color-background` is `neutral-950` in dark and `neutral-50` in light.
4. 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:

```slint
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:

```slint
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:

```slint
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:

```slint
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:

```slint
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:

```slint
import { Theme, ThemeMode } from "@glint/theme/tokens.slint";
export { Theme, ThemeMode }
```

They then appear on the generated type like any other global:

```rust
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:

```slint
import { Theme, Palettes } from "@glint/theme/tokens.slint";

Theme.palette = Palettes.neutral;  // the default — cold gray, tuned for OLED black
Theme.palette = Palettes.slate;    // cool gray, blue-tinted
Theme.palette = Palettes.stone;    // warm gray
Theme.palette = Palettes.zinc;     // neutral gray, slightly cooler than stone
```

Selection is reactive and applies to the whole tree. The Palette switch in the header is doing this to the components below right now:

```slint
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`:

```slint
import { Theme, Palettes } from "@glint/theme/tokens.slint";
export { Theme, Palettes }
```

```rust
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:

```slint
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:

```slint
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-50` is the lightest and `neutral-950` the 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 `600` is used in light mode and the `400` in 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`:

```slint
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. |
