Repository navigation
How the theme works
This page is made from docs/theme.md on every change to
main; edit it there.
MNML is the theme the cards are designed for, in two materials: glass (mnml.yaml) and flat (mnml-flat.yaml), both built from src/theme/ into custom_components/mnml/www/. Each defines a theme named MNML; the integration writes the one its Use the glass design option selects to config/themes/mnml-integration/mnml.yaml, and swaps it, reloading the themes, when the option changes. The cards depend on no theme: they use theme variables only, each with a fallback, and the theme supplies the values. The two themes set the same variables to other values: the glass one translucent, blurred and over a backdrop, the flat one opaque. Under another theme the variables are missing and each has a fallback, which draws the flat material in that theme's colours.
Shape and type, in both designs.
- The font is the system stack.
In the glass theme,
- Cards have a 22 px radius, and no border of the theme's own. The cards draw their 1 px hairline and highlight, and their shadow, from the variables below.
-
Glass. Cards, pills and pop-ups are translucent and blur what is behind them. Behind them is the view's background (
lovelace-background): a diagonal gradient under five soft glows, fixed to the window. The glows are in the model, so a change to their colour or strength is held to the contrast claims below. - Pop-ups have a 22 px radius, the cards', so a pop-up that unfolds in a tile's place keeps its corners.
In the flat theme, cards are the card colour, opaque, on a 1 px hairline in the edge colour, with no blur, no backdrop and no shadow of their own; pills and trays are opaque colours of their own, and pop-ups are the card colour. Everything else (the radii, the wells, the palettes and the type) is the glass theme's.
Two palettes under modes. HA switches between them with the operating system when a profile's dark mode is set to Auto. They are Monokai Pro's: Filter Spectrum in the dark, Monokai Pro Light in the light.
The whole interface. The palettes apply to all of Home Assistant, not only the dashboard.
GLASS and FLAT in src/theme/model.ts are the only things a change to the themes edits; THEMES lists them by design. FLAT is GLASS with a Material of opaque colours, no blur and no glows.
| Field | Holds |
|---|---|
name, font
|
The theme's name and font stack |
cardRadius, popupRadius, cardGap
|
Pixels. The gap is the spacing between a pop-up's cards, the same as a heading above a card |
primaryRamp |
HA's primary colour scale, steps 05 to 95, shared by both modes |
design |
glass or flat, which names the file written (mnml.yaml, mnml-flat.yaml) |
light, dark
|
A Palette each, with its Material
|
The theme also sets the gaps of a sections view to 12 px (ha-view-sections-column-gap, -row-gap).
A Palette has two opaque surfaces (the page and the card, which HA's menus and dialogs keep), five greys, the text colour for a primary fill, six accents and its Material. Every colour is a Hex, because HA derives the --rgb-* variants from hex values only; the material has a tint (a Hex and an alpha) for each of the card, the pill, the section (the trays of a pop-up), the raised pill (a pill on a pill), the well and its lip (the recess of a pill or a chip), the scroll shade of a pop-up, the overlay (a slider control's overlay, the tooltip), the shadow, the edge, the highlight and the scrim, the blur and saturation, the base gradient, and the glows. The pop-up's fill is the card's tint. Swapping in another Monokai Pro filter is a matter of replacing these values.
buildTheme() in src/theme/theme.ts spells the mapping:
| Palette | Home Assistant variables |
|---|---|
page |
primary-background-color, secondary-background-color, the app header, the sidebar, and the base of lovelace-background
|
card |
card-background-color (menus, dialogs), text-accent-color (the sidebar's unread badge on accent-color) |
material |
ha-card-background, ha-card-backdrop-filter, lovelace-background, and the mnml-* variables below: the card, pill, raised pill, section, overlay, edge, highlight, shadows, well and scrim |
text |
primary-text-color, the header and sidebar text |
dimmed |
secondary-text-color, the sidebar icons |
disabled |
disabled-text-color, the disabled line of a text field |
divider |
divider-color |
icon |
state-icon-color and grey-color. HA derives --state-inactive-color from the latter, so its own inactive icons take the theme's neutral |
onPrimary |
text-primary-color: text on a fill in the primary colour |
yellow |
primary-color, Monokai's accent; also amber-color, yellow-color, the sidebar's selected icon, ha-color-focus
|
purple |
accent-color, the fill of a slider with no colour of its own; also purple-color, deep-purple-color, indigo-color
|
red |
red-color, pink-color, error-color
|
orange |
orange-color, deep-orange-color, warning-color
|
green |
green-color, light-green-color, success-color
|
blue |
blue-color, light-blue-color, cyan-color, teal-color, info-color
|
The semantic colours (error, warning, success, info) follow the accents, so HA's own dialogs match them too.
The text on a primary fill is the card colour, so a filled button or the sidebar's selection reads against it.
- In the dark palette, that is Monokai's own background behind the bright yellow.
- In the light palette, the yellow is dark enough that nothing darker clears 4.5:1 on it.
In the light palette, the accents are darker than a Monokai Pro Light editor sets them.
- Why: a list row colours its value text, not just an icon. So each accent the cards use (red, orange, amber, blue) clears 4.5:1 against the card colour. The editor's own values sit near 3:1, and would fail as text.
-
Green too. No card names it, but HA renders
success-coloras the text of anha-alert.
HA's newer colour layer has two tiers, and the theme addresses the lower one.
-
Core scales. 57 scales (
--ha-color-neutral-05...-95,-primary-,-red-,-orange-,-green-) hold literal hex values. -
Semantic tokens. About 189
--ha-color-*tokens, allvar()references into those scales.
So a theme that replaces a scale reaches every token derived from it, and never has to name a semantic token.
| Scale | The theme |
|---|---|
primary |
Replaced (primaryRamp). It is spelled once: a scale is shared by both modes, and each mode's semantic tokens pick their own step |
neutral |
Left alone. HA's ladder already lands within 1.5 L* of the theme's surfaces: 05 is 6 against a page of 8.8, 10 is 12 against a card of 13.2, 20 is 23 against a pill of 22.3, and 95 is 96 against a card of 96.6. Re-tinting it would be churn with contrast risk and no visible gain |
red, orange, green
|
Left alone. They mean danger, warning and success, not brand, and HA's values already read correctly |
Derived, not written. HA's theme applier re-resolves every declaration whose value contains var() against the theme's own overrides. So the theme writes only what it has to decide, and the rest follows from the palette: all 84 --state-*-color, all 13 --mdc-theme-* and the semantic tokens.
- Four written keys are belt and braces, resolving to what HA would derive anyway:
ha-card-background,ha-font-family-heading,sidebar-selected-icon-colorandapp-header-text-color. -
ha-color-focusis spelled although HA derives it. HA derives it fromneutral-60, a fixed grey at 2.36:1 on this theme's light page.
Out of reach: the toast. HA's toast reads --ha-color-neutral-10 directly. That is a scale step, so theming it for the toast's sake would also move surface-default in dark mode.
src/theme/contrast.ts asserts every contrast figure that the documentation claims about each theme, in both modes:
- the accents, as values on the card and as icons on the pill;
- the text on a primary fill;
- headings and state lines;
- the focus ring;
- text, state lines, headings, accents and icons on the glass, including an accent icon on its own 16 % tint, over the page, both ends of the base gradient and the peak of each glow over each end, which is the worst case;
- the primary ramp's buttons, links and fills.
pnpm build refuses to write the theme when one stops holding, and contrast.test.ts runs the same assertions under pnpm check.
When a palette changes, fix the claim or the colour. The build won't let them disagree.
The cards read eighteen variables of their own, below. The theme sets each; without it, each card falls back as below.
| Variable | Read by | Without the theme |
|---|---|---|
mnml-pill-color |
pills, filled controls, slider tracks | an 8 % mix of the text colour (--m-pill) |
mnml-pill-raised-color |
a pill on a pill: every pill inside a pop-up | --card-background-color |
mnml-card-background-color |
every card's surface (--m-card) |
--card-background-color |
mnml-card-backdrop-filter |
every card's blur of what is behind it | none: the card is opaque |
mnml-card-edge-color |
every card's hairline | a 12 % mix of the text colour (--m-edge) |
mnml-card-highlight-color |
the 1 px light line along a card's top edge | none |
mnml-card-shadow |
every card's elevation | none |
mnml-button-shadow |
the well of a pill, a chip, the Lights row and a slider track: an inner shadow along its top and a light lip along its bottom | none |
mnml-overlay-background-color |
a slider control's overlay and the tooltip | --card-background-color |
mnml-popup-background-color |
the pop-up shell: a card's fill | --primary-background-color |
mnml-popup-section-color |
the cards inside a pop-up: the trays it holds | the pill colour |
mnml-popup-backdrop-filter |
the pop-up shell's blur | none |
mnml-popup-scrim-color |
the dimming behind a pop-up | rgb(0 0 0 / 0.55) |
mnml-popup-scrim-filter |
the blur of the page behind a pop-up | none |
mnml-popup-shadow |
the pop-up shell's elevation | none |
mnml-popup-scroll-shadow |
the shade at the foot of a pop-up with more below | none |
mnml-popup-border-radius |
the pop-up shell | a fallback radius |
mnml-popup-gap |
the pop-up shell, and the gap between the clients and messages cards' own cards | a fallback gap |
Beyond the two slider gradients, the cards write three colours of their own (see Design).