Styling

Styling

A widget’s style is plain CSS, with a few things to know: where it applies, what to read from the theme, and what it can’t do.

Scope: the Shadow DOM

Each tile draws its widget inside its own Shadow DOM. Your CSS applies to that widget and nothing else, and the page’s styles don’t reach in. That means you can call a class .big or .name without worrying that another widget has one too.

The partials a widget renders share its scope: their styles are added after the widget’s. So a partial starts its class and @keyframes names with its own name (.sprinkler-on, @keyframes sprinkler-spin), and a widget’s own names stay clear of them.

The tile itself is :host, and your template sits inside it as if its top-level elements were the host’s children. So :host is the place to lay the widget out:

:host {  display: flex;  flex-direction: column;  gap: 6px;}

Before your style, every widget gets a short base style: :host is a block that fills the tile, has position: relative and is a container for container queries. You can override any of it.

Use the theme’s variables

Colours, fonts, corners and spacing come from the theme through CSS variables, and they all start with --hd-. Reading them makes your widget look right in every theme, and in a dark room or on e-ink, without a line of its own for each:

.card {  padding: var(--hd-pad);  border: var(--hd-border-width) solid var(--hd-border);  border-radius: var(--hd-radius-md);  background: var(--hd-surface-raised);  color: var(--hd-text);}.label {  font: 500 11px var(--hd-font-mono);  letter-spacing: var(--hd-label-tracking);  text-transform: var(--hd-label-transform);  color: var(--hd-text-muted);}.number {  font: var(--hd-number-weight) 44px / 1 var(--hd-font-display);}.on {  color: var(--hd-lit);}

The style variables reference lists every one: surfaces and text, accent and light, states and chart colours, numbers and type, shape and depth, and motion. Themes and styling explains the themes themselves.

Don’t write colours like #ff9900 into a style unless you mean it. A fixed colour doesn’t follow the theme, so it can clash with it, and it ignores the flat look of e-ink. For a colour the tile’s owner should choose, add a colour setting.

Fit any tile size

A tile can be small or large, and the same widget has to draw well in both. :host is a container, so use container queries instead of media queries: they respond to the tile’s size, not the screen’s.

.big {  font-size: 36px;}@container (min-width: 210px) {  .big {    font-size: 48px;  }}@container (max-width: 160px) {  .zone {    display: none;  }}

By default the container measures width only. To measure the height as well, set container-type: size on :host:

:host {  container-type: size;}@container (min-height: 230px) {  .slider {    display: block;  }}

Sizes inside a container can also be written as a share of it: cqi is one percent of the container’s width and cqh one percent of its height. font-size: min(26cqi, 58cqh) is a number that grows with the tile and always fits.

Where the content sits

A tile’s owner can choose where its content sits, up and down and sideways, in the tile’s Style panel. The choice reaches your style as --hd-item-align and --hd-item-justify, and your style reads them with its own default as the fallback:

.body {  display: flex;  flex-direction: column;  justify-content: var(--hd-item-align, flex-end);  align-items: var(--hd-item-justify, stretch);}

Which directions are offered, and each one’s default place, is the manifest’s align, which the widget you started from decides. The style variables reference lists the variables that describe the tile’s layout.

Fill the whole tile

Your template sits inside the tile’s padding, so a background on :host stops short of the tile’s edge. To paint the tile edge to edge, such as a red wash while an alarm goes off, wrap the template in one element and pull it out over the padding, as the built-in widgets do:

<div class="tile{% if cap.alarm_smoke.value %} alarm{% endif %}">  …</div>
:host {  display: flex;}.tile {  --scale: var(--hd-item-scale, 1);  flex: 1;  display: flex;  flex-direction: column;  box-sizing: border-box;  min-width: 0;  min-height: 0;  margin: calc(var(--hd-item-pad, var(--hd-pad)) / var(--scale) * -1);  padding: calc(var(--hd-item-pad, var(--hd-pad)) / var(--scale));  border-radius: calc(var(--hd-item-radius-tl, var(--hd-radius-lg)) / var(--scale))    calc(var(--hd-item-radius-tr, var(--hd-radius-lg)) / var(--scale))    calc(var(--hd-item-radius-br, var(--hd-radius-lg)) / var(--scale))    calc(var(--hd-item-radius-bl, var(--hd-radius-lg)) / var(--scale));}.tile.alarm {  background: color-mix(in oklab, var(--hd-danger), transparent calc(80% + var(--hd-flat) * 20%));}

The negative margin takes the padding back and the padding puts it inside again, so the content stays where it was. --hd-item-pad is the tile’s own padding when its owner set one, else the theme’s --hd-pad. --hd-item-scale is the tile’s content size: the widget is zoomed by it, so the padding is divided by it to come out the same. --hd-item-radius-tl, -tr, -br and -bl are the tile’s own corners, as its owner or the dashboard set them, so the paint follows them.

E-ink

On an e-ink screen the theme sets --hd-flat to 1: no colours, no glows, no gradients, only black and white. On any other screen it is 0. Use it in calc() to fall back to a plain colour:

.dot {  --colour: color-mix(in oklab, hsl(40 90% 60%), var(--hd-text) calc(var(--hd-flat) * 100%));  background: var(--colour);  box-shadow: 0 0 10px color-mix(in oklab, var(--colour), transparent calc(var(--hd-flat) * 100%));}

With --hd-flat at 0 the dot is amber and glows. At 1 it is the text colour and the glow is fully transparent.

--hd-appearance says light, dark or paper, for the few things colours alone can’t decide. Wall panels and e-ink explains the screens themselves.

Motion

Themes decide how much may move. Don’t write your own durations: take them from the theme, and everything calms down when the theme asks for less motion.

.big {  transition: color var(--hd-motion-base) var(--hd-ease-out);}.pulse {  animation: pulse var(--hd-motion-slow) var(--hd-ease-out) infinite alternate;}@keyframes pulse {  from {    opacity: 0.6;  }  to {    opacity: 1;  }}

--hd-motion-fast, --hd-motion-base and --hd-motion-slow are durations, and all three are 0ms when the theme lets nothing move. --hd-motion-level says full, reduced or none for the components that need to know. The hd-* components follow it themselves.

What CSS is allowed

A style is checked when you save, and each problem is listed with its line. These rules keep a widget from loading or running anything:

  • No url(), image-set(), src() or any other function that loads something from elsewhere.
  • Only these at-rules: @media, @container, @supports, @keyframes, @starting-style and @layer. There is no @import, @font-face or @namespace.
  • No expression(), behavior or -moz-binding, and no javascript: in a string.
  • No escape sequences in names. u\72l( is url( to a browser, so a backslash in a name is refused.
  • The syntax has to be valid CSS.
  • A style can be 20,000 characters long.

Fonts come from the theme (--hd-font-display, --hd-font-ui and --hd-font-mono), and pictures come from components such as hd-image and hd-icon.

The inline style attribute in a template follows a tighter rule: it can only set custom properties, and a value can’t contain brackets, so style="--fill: 62%" is fine and style="--fill: calc(62% / 2)" is not. Set the variable inline and do the calculation in the style:

<div class="bar" style="--fill: {{ cap.measure_battery.value }}"></div>
.bar {  width: calc(var(--fill) * 1%);}

Tile CSS and the widget’s style

There are three layers of CSS on a tile, and a later one wins over an earlier one at the same specificity:

  1. The widget’s style is what you write in the editor. It applies to every tile that uses the widget.
  2. The theme’s own CSS, which a theme can add on top of every widget.
  3. The tile’s own CSS is written for one tile, in the Style panel’s CSS tab. It is free on every plan, and it reaches only that tile.

The tile’s CSS is for adjusting one tile of a widget, without touching the others or making a widget of your own. It follows the same rules as above and a few more, since it must stay inside its tile:

  • Up to 4,000 characters.
  • No :root, html, body or :host-context.
  • position can be static, relative, absolute or sticky, but not fixed.

Settings of your widget reach its style as --setting- variables, and the settings page shows how.