Themes are made in the browser, under Settings › Appearance, and stored on your openHAB server. You never edit a file, and a theme travels with your backups.
This page is for going past the colour pickers.
- Settings › Appearance › New theme. You get the colours of whatever theme is on screen. Not its stylesheet: a structural theme's CSS has colours written into it that would fight the tokens you are about to change.
- Change them. The screen updates as you type. Nothing is stored until Save theme.
- Dark scheme tells the browser which way round the page is, so scrollbars and native form controls come out matching your background.
- Saving does not switch anyone else over. Tick Use on all devices for that, or pick it under Theme on this device to use it only here.
- Export with the ⭳ on its card to hand the file to someone. Settings › Backup › Import configuration is how one comes back, always as a copy, so a shared theme cannot land on top of yours.
Built-ins have no ⭳ and no ✎. To change one, make a new theme from it.
A theme applies to everything, including the Settings screen you would use to undo it, and it is
cached locally, so reloading reapplies it. Add ?theme=none to the address:
http://your-server:8080/neohab/index.html?theme=none
That gives you neohab Dark for one page load. It works before or inside the #, ignores the shared
theme, this device's override and the cache, and saves nothing. From there, edit or delete the
offending theme normally. A built-in's id works too (?theme=light) if you want to look at one
without adopting it, and anything neohab does not recognise falls back to neohab Dark.
A theme is a set of design tokens, applied as CSS custom properties named --nh-<token>. Widgets
and chrome only ever read tokens, so changing one restyles everything that uses it.
Every token is optional. One you leave alone keeps neohab's own value, so a theme can be four colours or all 27.
| Token | What it paints |
|---|---|
bg |
The page, and the top bar |
surface |
A widget tile, a sheet, a Home tile |
surface-2 |
A step above that: buttons, dropdowns, slider tracks |
border |
Every hairline: tile edges, field outlines, separators |
text |
The main ink: readings, labels and controls |
text-dim |
Widget names, captions, hints and units |
primary |
The accent: active controls, gauges, the tile-accent setting |
brand |
neohab's own colour: the wordmark, primary buttons, editor handles |
radius |
Corner rounding. 0px gives square corners |
shadow |
The drop shadow under a tile. none makes the design flat |
| Token | What it paints |
|---|---|
good |
Something going right: a value tile's trend badge, a healthy battery, a live indicator |
bad |
Something wrong: an error line in the log, a flat battery, a save that failed |
accent-ink |
Text drawn on the accent: filled tiles, chips, badges |
Leave accent-ink unset and neohab measures your accent and picks white or near-black, whichever
can be read on it. Set it only to override that. The ink on brand gets the same treatment and has
no token of its own.
chart-1 … chart-8 colour chart series and timeline states that have no colour of their own.
Unset, you get a built-in palette checked for colour-blind separation. Pin only chart-1 and the
rest of that palette stays intact.
face-hi, face-lo, band-light and band-shade do nothing until a theme sets them, so gauge
faces stay flat. rim-hi and rim-lo fall back to border: the rim is there, just unshaded.
| Token | Effect |
|---|---|
rim-hi / rim-lo |
Shades a gauge's outer rim, lit at one end |
face-hi / face-lo |
A glow inside the gauge face |
band-light / band-shade |
0 to 1. Bright film at the tip of a solid-arc band, sunk film at its start |
The editor scores each pair of colours that actually meets on screen. 4.5:1 is the guideline for normal text, 3:1 for large. It is a warning rather than a limit, but red numbers mean the theme is genuinely hard to read.
Only a hex or an rgb() value can be measured. A color-mix(), an hsl(), a var() reference or
a named colour is reported as not measurable rather than guessed at.
Tokens cannot change fonts or the shape of a widget. For that a theme can carry a stylesheet, applied with it and removed when you switch away: Settings › Appearance › edit a theme › Custom CSS. It previews as you type, like everything else.
This is real CSS with no sandbox, stored in your openHAB configuration like the rest of your setup. Treat a theme file from someone else the way you would treat any other code from them.
The editor checks your stylesheet against the rules below as you write it, and lists anything it finds under the box. It is advice, not a refusal: the CSS is applied either way.
One place your selectors cannot reach is inside a template widget, which renders in a shadow root.
The --nh-* tokens do inherit through it, and a template can read the values too: theme.primary,
and theme['text-dim'] for the keys with a hyphen in them. So style those widgets from the
template's own side.
The frame every widget draws:
| Class | What it is |
|---|---|
.nh-widget |
One widget's card |
.nh-widget--bare |
It asked for no card (a label, a clock set to show none) |
.nh-widget--headed |
It drew a name row, which costs about 1.05em plus 8px. A height threshold needs a value for each case |
.nh-widget__label |
The name row. __labelmain is the icon and name together, __labeltext the name alone, __aside whatever is parked on the right |
.nh-widget__body |
Everything below the name row |
.nh-labelbottom |
On the cell: this widget's name row sits under the body |
Chrome:
| Class | What it is |
|---|---|
.nh-tile |
A Home-screen dashboard tile. .nh-tile--new is the "+ New dashboard" one |
.nh-dash__bar |
The top bar |
.nh-side |
The dashboard sidebar |
.nh-sheet, .nh-detail |
A settings sheet, and the panel a long press opens on a tile |
.nh-group |
The frame around a panel group |
.nh-iconbtn, .nh-chip |
The round icon buttons in a bar, and the pills (chart ranges, log filters, floor-plan presets). .nh-chip--on is the picked one |
.nh-acc-filled, .nh-acc-tinted, .nh-acc-outlined |
On the cell, from the per-widget Tile accent setting |
Controls:
| Class | What it is |
|---|---|
.nh-button--plain |
The button's plain finish, and the one to restyle: the seven others (solid, glass, glow, edge, outline, sheen, bare) fill the tile and keep their own look in every theme on purpose. .nh-button carries only layout, --card is the card style, --active the on state |
.nh-switch__track, .nh-switch__thumb |
The sliding toggle, which is the button in its switch style. .nh-switch--on marks the on state, .nh-switch__state is the ON/OFF caption |
.nh-slider, .nh-slider__input, .nh-slider__value |
The plain range control, and the one to restyle: the slider widget's plain style, and what a long press or a floor-plan light puts in front of you |
.nh-fader__* |
The slider's four other styles (gradient, wedge, inset rail, bubble), which keep their own look. __read is the reading, __bound the ends of an inset scale, __track and __fill the two painted layers |
.nh-step--plain .nh-step__box, .nh-step__ctl, .nh-step__split |
The stepper's plain finish, and the one to restyle; its four others (glass, glow, solid, sheen) keep their own look. Square it with --st-radius on .nh-step, since the radius token does not reach inside |
.nh-selection__btn, .nh-roller__btn, .nh-player__btn |
Selection, rollershutter and player buttons, each with an --active modifier |
.nh-color__swatch, .nh-color__track, .nh-color__pbtn |
The colour picker: the swatch, its three channel sliders, and the on/off pair drawn on the swatch |
.nh-thermo, .nh-thermo__btn, .nh-thermo__mbtn |
The thermostat. Its root carries --th-heat, --th-cool and --th-mid, the colours its face and temperature ramp draw in; .nh-thermo--heat, --cool and --neutral say which is in force |
Readings and instruments:
| Class | What it is |
|---|---|
.nh-stat__value, .nh-stat__frac, .nh-stat__unit, .nh-stat__caption, .nh-stat__badge |
The reading, its tenths, its unit, the small line under it and the trend marker beside it. All eight of the value widget's styles draw through these, so one rule reaches every one. .nh-stat is the stat style's own column, with --center and --right for its alignment |
.nh-value__text, .nh-value__unit |
A reading and its unit in the value widget's plain style, set separately |
.nh-read, .nh-read__main |
The root of the six styles added after plain and stat, with .nh-read--spark through --hero saying which is drawn. __plot, __line and __area are the sparkline, __track and __fill the bar, __disc the split's icon circle, __pill the pill. Size the reading itself on .nh-stat__value above, not here |
.nh-gauge__*, .nh-dial__* |
Gauge and dial internals |
.nh-compass__* |
The compass face |
.nh-battery, .nh-battery__num, .nh-battery__cap |
The battery, its number and its charging caption. The root carries the level colours --bt-good, --bt-mid (the one amber the tokens do not have) and --bt-low, plus --bt-accent; --glow through --meter say which of the eight styles is drawn, [data-level] which level is in force. Its glyphs are painted inline from --bt-color, so recolour through the variables, not the SVG classes |
.nh-clock__time, .nh-clock__date |
The clock |
.nh-chart__chip, .nh-chart__legend |
Chart chrome |
.nh-tl__row, .nh-tl__name, .nh-tl__track, .nh-tl__band, .nh-tl__axis |
The timeline: one row per item, its label, the strip, one state's band, and the clock underneath |
.nh-weather, .nh-weather__temp, .nh-weather__cond, .nh-weather__details |
The weather panel. --hero, --compact and --striplook say which layout is drawn, __strip and __col are the forecast columns |
.nh-log, .nh-log__line, .nh-log__level |
The log console. The root carries --lg-warn and --lg-error (error follows bad unless you set it); __line--warn and --error mark the lines, __time, __logger and __msg are the columns, __jump the pill back to the newest line |
.nh-fplan__img, .nh-fplan__glow, .nh-fplan__marker, .nh-fplan__bar |
The floor plan: the plan image (__img--blueprint or --ink), a light's pool of colour, its dot, the preset bar. --nh-glow-blend on the root is the blend mode the plan style picked, and it is the only reason a lit room shows at all, so leave it be |
.nh-camera, .nh-image, .nh-frame, .nh-template |
The camera, image, iframe and template hosts. Each has its own status or placeholder child. .nh-image--contain is an image set to show the whole picture rather than fill the tile |
When a widget cannot show anything:
| Class | What it is |
|---|---|
.nh-widget--notice |
The centred message state, inside the normal frame |
.nh-widget__errtitle |
Its headline, usually the name of an item the server does not have |
.nh-widget__errtext |
The technical line, monospaced and clipped to one line |
.nh-widget__errhint |
The explanation, shown only in a cell with room for it |
.nh-widget__unset |
A widget nobody has picked an item for yet |
These live on each grid cell rather than on the root, and a theme can read all of them:
--nh-cellfontis that cell's computed font size, with the dashboard, device and per-widget text scales already folded in. Use it for anything that must not shrink twice: a control that moves into the name row inherits 0.8em there, and anemon top of that is unreadable.--nh-cellaccentis that tile's own Accent colour, if it has one.--nh-accent-inkis recalculated on the cell to match it.--nh-labelalignis the tile's Name alignment. A theme may set a default on.nh-gcell, .nh-cell; a widget that made its own choice still wins, because that arrives as an inline style.
Your stylesheet loads after neohab's, so a rule of equal specificity beats the base one. That is what makes theming work, and it is why these six exist. The editor checks all of them, and so does the test suite for every built-in theme.
1. Gate your padding. neohab sheds padding in short and narrow cells so text stays readable.
An ungated .nh-widget__body { padding: … } wins over those sheds and puts the clipping back.
Gate yours on the complementary range:
@container (min-height: 105px) and (min-width: 121px) {
.nh-widget__body { padding: 8px 10px 10px; }
}2. Style both halves of a control state. .nh-button--plain and .nh-button--plain.nh-button--active
have the same specificity, so styling the base flattens the active state. The same goes for
.nh-selection__btn--active, .nh-chip--on, the dashed .nh-chip--action and the listening
.nh-iconbtn--live. Style both. The editor says so as you type.
3. Never set fill or stroke on these. Their paint is an attribute the widget computes,
such as a per-instance gradient, a severity colour or a live tint, and a stylesheet declaration
beats an attribute:
.nh-gauge__rim .nh-gauge__band .nh-gauge__bandlight .nh-gauge__bandshade
.nh-gauge__ledlit .nh-gauge__blklit .nh-gauge__claybody .nh-gauge__tklit
.nh-compass__cardinal .nh-compass__value
Width, opacity, font and filter are all fine. Just not the colour.
4. border-image needs radius: 0. A border gradient squares off rounded corners, so the two
cannot be combined. Set the radius token to 0px if you want a gradient bezel.
5. Put back the two tiles a blanket rule breaks. If you style .nh-widget, .nh-tile as a
group, restore these:
.nh-widget--bare { /* a widget that asked for no card */ }
.nh-tile--new { /* keep this a dashed invitation, not a real dashboard */ }If panelling the bare widgets is deliberate, put nh-theme-allow: bare-panelled in a comment and
the check stands down.
6. Only reference bundled assets. A url() must point at fonts/, backgrounds/ or
icons/, or be a data: URI. Anything else will not load on a server with no route to the
internet, which is most of them. What is in fonts/, each with its licence beside it:
poppins-400.woff2, poppins-500.woff2 and poppins-600.woff2, one @font-face each;
montserrat.woff2, which is variable and wants format('woff2-variations') over a 100 900
range; and dseg7.woff2 and dseg14.woff2, the seven- and fourteen-segment LCD faces. Declaring a
static file as woff2-variations gets you no font at all, so match the file.
A flat theme with square corners and an outlined button:
/* Several controls carry a hardcoded radius, so the token alone will not square them. A finish the
user picked takes the tile's own radius, so the token is enough for those. */
.nh-button--plain,
.nh-iconbtn,
.nh-selection__btn,
.nh-roller__btn,
.nh-player__btn,
.nh-chart__chip,
.nh-color__swatch,
.nh-switch__track,
.nh-switch__thumb,
.nh-log__jump {
border-radius: 0;
}
/* The stepper builds its own corners from a variable of its own. */
.nh-step { --st-radius: 0; }
/* The two range-track pseudo-elements must stay in separate rules: grouped, the whole rule is
invalid in whichever engine does not know the other's prefix. */
.nh-color__track::-webkit-slider-runnable-track { border-radius: 0; }
.nh-color__track::-moz-range-track { border-radius: 0; }
/* Tighter insets, gated so the small-cell sheds still win where they are needed. */
@container (min-height: 105px) and (min-width: 121px) {
.nh-widget__body { padding: 8px 10px 10px; }
}
/* Both halves of the control state, for every control that has one. Touching the base of one of
these and not its --active is what rule 2 is about, and the editor will say so. */
.nh-button--plain { background: transparent; border: 1px solid var(--nh-border); }
.nh-button--plain.nh-button--active { background: var(--nh-cellaccent, var(--nh-primary)); color: var(--nh-accent-ink); }
.nh-selection__btn { background: transparent; border: 1px solid var(--nh-border); }
.nh-selection__btn--active { background: var(--nh-cellaccent, var(--nh-primary)); color: var(--nh-accent-ink); }The editor offers to copy the stylesheet of the theme you are looking at. Know what you get:
- Swiss Sheet and Ember are written almost entirely in
color-mix()off the tokens, and neither bundles a font. That is how one Swiss stylesheet serves both its dark and light variants. Copy either of these if you want a starting point that follows the colours you then change. - LCD Console, Operations and Assembly have colours written into them, bundle a font, and in Assembly's case a background image. They will not follow the tokens you change. Copy them to study, not to recolour.
All seven of HABPanel's themes have a port here: Default (as Aqua), Material, Material dark, Pale blue, Translucent, Madras and Orange Tree. An import picks the matching one automatically. They are ports, not clones: where a colour fell below a readable contrast ratio it was moved the smallest distance that fixes it.
HABPanel's additional_stylesheet_url is not imported. Its selectors are HABPanel's, which
neohab does not have. Paste what you want to keep into a theme's Custom CSS and translate the
selectors using the tables above.
| File | What it is |
|---|---|
web/src/themes/tokens.ts |
The token contract. Adding one here puts it in the editor and in this page's tables |
web/src/themes/themes.ts |
Applying a theme, the built-ins, the local cache |
web/src/themes/active.ts |
Which theme is in effect: the URL override, then this device's, then the shared one |
web/src/themes/urlTheme.ts |
The ?theme= escape hatch, read once at load |
web/src/themes/cssRules.ts |
The six rules, checked in the editor and in the tests |
web/src/themes/contrast.ts |
Contrast maths and the automatic ink choice |
web/src/themes/css/shared.ts |
The recurring idioms above, as reusable pieces |
web/src/themes/css/*.ts |
One stylesheet per built-in, loaded only when used |