cd /news/developer-tools/handover-for-ha-dashboard-https-www-… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-124264] src=gist.github.com β†— pub= topic=developer-tools verified=true sentiment=Β· neutral

Handover for HA dashboard https://www.reddit.com/r/homeassistant/s/t5HpCF2vcx

A developer has documented a method for building hand-illustrated, animated floorplan dashboards for Home Assistant using the stock picture-elements card, with images that swap based on device states. The approach uses one image per floor, animated WebP files for device animations, and is designed for wall tablets or TVs, though it requires significant illustration work and manual placement.

by read43 min views1 publishedSep 8, 2026

Who this is for: an AI agent (or engineer) helping someone build an illustrated, animated floorplan dashboard for Home Assistant from scratch, with their own house, their own illustrations, their own sensors.

How to read it: it is written as questions and answers. Find the question the user is really asking and answer from that section. The numbers, room names and entity IDs here are placeholders β€” every one of them will be different in a new build. What transfers is the method: the canvas rule, the file naming scheme, the card patterns, and a long list of traps that cost real time to discover.

Nothing here requires the original artwork. A new build can have one floor or five, three devices or thirty.

A hand-illustrated, isometric cutaway of a house, one image per floor, used as the home screen for Home Assistant. Devices are painted into the picture. When the air conditioner runs, the AC in the picture blows; when the TV is on, the screen glows and flickers; at night the whole house switches to a night render with warm windows. Small translucent "pills" sit over each room showing temperature, humidity and the AC setpoint.

It is built on Home Assistant's stock picture-elements card. The illustration is the card's background; every device is an absolutely-positioned image element on top of it that swaps its own file when the entity changes state.

One Lovelace view per floor or area, each a full-screen panel holding a single picture-elements card. A reference build had four:

view what it holds
Ground floor floorplan + AC units, TV, wood stove + two room pills
First floor floorplan + AC units, monitor + four room pills
Garden three background states (day / night-dark / night-lit), tap zones on the lamps
Combined all three floors composited onto one canvas, sized for a TV

A new build can stop at one view. The combined view (Part 10) is genuinely optional and is the hardest part of the whole project.

  • It is beautiful, and it is legible. People who do not use Home Assistant can read it. "The living room AC is on" is a picture of an AC that is on.

  • Almost entirely stock. The core (backgrounds, day/night, animated devices, tap-to-open) needs no custom cards at all. Only optional extras pull in HACS.

  • Animation costs nothing at runtime. The motion lives inside the image files (animated WebP), not in CSS or JavaScript. The browser decodes an image. This is what makes it viable on a cheap wall tablet or a TV browser.

  • It scales down gracefully. Coordinates are percentages, so the card fills whatever screen it lands on.

  • Excellent for a wall display or TV. Big, glanceable, no chrome, no scrolling.

  • The artwork is the project. Expect the illustration to be 80% of the cost and the calendar time. If the user cannot commission or produce a consistent isometric set, stop here and suggest a photograph-based or SVG floorplan instead.

  • Every device state is another drawing. Adding a lamp later means going back to the illustrator. This is the single biggest structural downside β€” see the file count arithmetic in Part 4 before promising anything.

  • Placement is manual. The background does not contain the devices, so nothing can auto-locate them. Each device is dragged into position by hand, once.

  • picture-elements is a blunt tool. No layout engine, no grouping, no boxes. Everything is absolutely positioned, so hiding an element leaves a hole. There is one important exception (Part 9).

  • It is not adaptive. A phone gets the same picture, shrunk. If phone use matters, the floorplan is a second dashboard, not the only one.

  • Maintenance is YAML. No GUI editing β€” the visual editor actively corrupts this card (Part 11).

  • Weight. A full art set runs a few megabytes. Fine on a LAN, noticeable over a slow remote connection.

  • They have no illustration source and no budget for one.

  • They want every entity in the house on it. This design suits ~4–8 visible devices per view; past that it turns into noise and the file count explodes.

  • They mostly use their phone.

  • They want to add devices frequently. Each addition is an art request.

A good compromise to offer: build one view for the main living space, with two or three animated devices and the room pills. It is a fraction of the work and delivers most of the impression.

requirement why
Home Assistant with file access ( /config ) the dashboard is YAML files on disk β€” Samba, SSH add-on, VS Code add-on, anything
Willingness to run the dashboard in YAML mode !include of shared element files is the whole architecture
An illustration set, or a way to get one see Part 3
A helper toggle for day/night input_boolean
Sensors that already work in HA the dashboard displays entities, it does not create them
An image tool that can encode animated WebP ( img2webp , fromlibwebp ) plus pngquant for palette reduction

Start with none. Then, per feature:

feature needs avoidable?
backgrounds, day/night, animated devices, tap-to-open nothing β€”
room status pills nothing β€”
a popup controlling two or more entities from one tap Browser Mod yes β€” use a subview, or tap/hold for two dialogs
the combined all-floors view card-mod no
scaling a standalone view fluidly ( --fp-u ) card-mod yes, if only fixed-px sizing is wanted

Tell the user plainly which line they cross and when. A stock-only build is a real option and worth defending.

Anything reasonably current. Two version-sensitive points:

  • The modern condition schema (condition: state ,condition: numeric_state ,condition: or ) works inpicture-elements conditionals from frontend2024.2 onward. Older versions only take the legacy shorthand (entity: +state: ).
  • The conditional -as-flex-container behaviour in Part 9 was checked against release tags spanning several months, not justdev β€” the exemption is in the shipped builds, not a recent addition. Re-check if the target is much older.

Ask, because it constrains CSS. A reference build targeted a smart-TV browser, which ruled out:

  • flexbox gap (Chromium 84+) β€” use margins on children instead
  • 100dvh (Chromium 108+) β€” use100vh

Modern tablets and desktops have no such limits. If the target is a TV or an old tablet, keep to the conservative forms throughout; it costs nothing.

Every image must be exported on one shared canvas of the same size, with content positioned where it belongs in the house β€” not cropped to itself.

Call it the master canvas. Pick a size once (a reference build used 4096Γ—2160 RGBA PNG), and have the illustrator render every single asset onto it: each floor's day render, each floor's night render, and every device layer, all at their true position inside that canvas, transparent everywhere else.

Three payoffs, all large:

  1. The floors line up automatically. Compositing all floors into one image later becomes a plain alpha stack β€” no registration work, no guessing.
  2. Device layers land themselves. If a device is exported full-canvas, its position is already known; nobody has to drag it into place.
  3. Coordinates survive. Every position can be expressed in master-canvas pixels, which stay valid even if a crop box changes later. Converting percentage-to-percentage between two different crops is where mistakes happen β€” always go via master-canvas pixels.

In the reference build the backgrounds were full-canvas but the device layers were pre-cropped, so the devices had to be placed by hand with a drag tool. That is the avoidable half. Ask for full-canvas device layers up front.

Per view: find the content bounding box in the alpha channel, and crop to it β€” never resize. Source pixels then map 1:1 to the served image, so every measurement stays meaningful.

Record, for each view, its crop origin and size. Those four numbers convert any master-canvas pixel to a card percentage:

left% = (Xcentre βˆ’ cropX) / cropW Γ— 100
top%  = (Ycentre βˆ’ cropY) / cropH Γ— 100

Store them in a small _meta.json beside the built images. Future-you and future tools will need them.

The union of both. Always.

Night renders bleed outward β€” light spills from windows, so the opaque area extends further left and down. In the reference build the difference was 151 px left and 88 px down on one floor. Crop each render to its own box and the entire floorplan visibly jumps the moment the card swaps day for night.

Crop day and night to the same union box. Both files then have identical dimensions and are pixel-aligned, and the swap is invisible. The same rule applies to any other whole-scene variant (a "lights on" night render, for example).

  1. One isometric cutaway per floor/area, day andnight versions, on the master canvas.
  2. For any whole-scene lighting state (garden lamps on, for instance): an additional full render.
  3. For every animated device: a still "off" frame and an8-frame loop for "on", inboth day and night lighting, each on the master canvas.
  4. Device layers with their own drop shadows, on transparency.
  5. Everything as PNG with alpha. No flattening, no baked backgrounds behind device layers.

Yes, and enforce it. A reference build had one folder capitalised inconsistently (FIreplace_Night with a capital I) which broke every glob that touched it. Agree a scheme, lowercase it, and check before building.

This is the section to read before quoting the user a scope.

One per whole-scene state, which is the product of every global condition:

situation files
day/night only 2 β€” <view>_day.webp ,<view>_night.webp
day/night + a scene light that changes the whole render 3 β€” <view>_day.webp ,<view>_night_off.webp ,<view>_night_on.webp

Note the asymmetry in the second row: a garden's lamps are invisible in daylight, so day needs no on/off pair. Only draw the combinations that actually look different.

Warning: this is a product, not a sum. Two independent global toggles = 4 renders per view; three = 8. Keep global states to one (day/night), plus at most one scene light per view. Anything else should be a device overlay, not a background.

For a device drawn as its own layer with an on/off state, in a house that has day and night backgrounds, it is four files:

file when it shows
<device>_off.webp day, entity off β€” a still
<device>_on.webp day, entity on β€” animated (or a still, if it does not move)
<device>_night_off.webp night, entity off β€” a still
<device>_night_on.webp night, entity on β€” animated

Rules that go with those four:

  • The night art must be geometrically identical to the day art β€” same frame size, same content position. That is what lets one set ofleft /top /width serve both. Assert it at build time.
  • The off still and the on animation must share one canvas size.state_image swaps the file but the element keeps onewidth and derives height from the image's aspect ratio. Different sizes = the artwork jumps and resizes on every state change. This bit the reference build exactly once, on a monitor whose glow spilled further in the animation frames than in the still (189Γ—284 vs 314Γ—319).Assert every frame equals the still before building a component.
  • Naming: keep _off /_on for day and_night_off /_night_on for night. It is asymmetric and slightly ugly; it is also easy to glob and easy to read.

state_image maps state β†’ file, so any number of states is fine β€” you just need a drawing for each visually distinct one. Two shapes come up:

  • Many states, one appearance. A climate entity hascool ,heat ,heat_cool ,auto ,dry ,fan_only andoff . If the AC looks the same whenever it runs, map all six active modes to thesame_on file. That is what the reference build does.
  • A rare alternate look. An occasional takeover (a special image on the TV screen, say) is best gated by a separateinput_boolean and drawn as extraconditional blocks, so it does not multiply the normal art. Same geometry, different file, so no coordinates change.

Two different mechanisms; pick per light:

  1. The light changes the whole scene (a garden's lamps, a room whose glow floods the render): make it abackground variant , a full-canvas conditional overlay atz-index: 0 . One extra render per state.
  2. The light is a local object (a lamp on a table): make it adevice layer with_off /_on , exactly like any other device. Four files.

Option 1 looks far better and costs one render; option 2 scales to many lights. Do not mix approaches for the same light.

One: a 1Γ—1 fully transparent WebP (about 34 bytes), per view folder. It is the carrier for invisible tap targets β€” you place it, give it a width and height in CSS, and it becomes a clickable rectangle over any part of the picture (the lamp posts in a garden, a whole room, a door). Stock picture-elements has no "box" element, so this is the standard workaround.

images = views Γ— background_variants
       + animated_devices Γ— 4
       + views Γ— 1            (the transparent hit-area carrier)

A three-view build with eight animated devices and one garden light: (2 + 2 + 3) + 32 + 3 = 42 served files, from roughly 8 devices Γ— (2 stills + 16 frames) = 144 source frames plus 7 background renders. Show the user that number before they commit.

WebP, throughout. Every browser worth targeting renders it, including the browsers built into smart TVs β€” those are Chromium-based and have been for many years. The widely repeated claim that a given TV "does not support WebP" almost always refers to its native media/photo viewer, not its browser engine; test it with one sample image before believing it. A PNG-only build was tried in the reference project and reverted.

asset recipe
Backgrounds lossy WebP, quality ~88. Around 2.4/255 mean channel error on visible pixels β€” invisible in practice
Device stills and animation frames pngquant --quality 85-100 first, then encode LOSSLESS WebP

That second row is counter-intuitive and worth internalising: for flat, cel-shaded art with a lot of transparency, lossy WebP is both larger and worse than lossless. One measured sprite sheet came out at 47 KB lossless-after-quantise versus 150 KB at q90. The colour reduction is what does the work; the lossless encode then preserves it exactly. Do not "optimise" this by switching to lossy.

Exception worth knowing: if a frame is busy raster content (a photographic image on a screen, not flat cel art), palette reduction at 85-100 will fail to quantise and fall through to a full-colour lossless encode. Drop that one asset to --quality 70-95 and check the error metric.

A full floor's bundle β€” day and night backgrounds, every still, every animation β€” lands around 1 MB with this recipe.

Animated WebP. The _on.webp file is the animation, looping forever. The card simply swaps it in with state_image. No CSS animation, no JavaScript, no custom card.

Build with img2webp:

img2webp -loop 0 -lossless -m 6 -d <ms_per_frame> \
    frame0.png frame1.png ... frame7.png -o <device>_on.webp

-d is milliseconds per frame, not a frame rate. -m 6 is the slowest/best encoding method β€” if you are batch-encoding many files under a shell timeout, -m 3 is a reasonable fallback.

Tune per device, on the actual target screen, and then leave them alone. Reference values, after tuning:

device kind frames per frame loop fps
air conditioner (fan blades) 8 167 ms 1.34 s 6
fire / flame 8 125 ms 1.00 s 8
TV / screen flicker 8 β†’ 6 encoded 125/250 ms 1.00 s 8

Do not normalise them to one speed, and do not try to make the loops the same length β€” nothing depends on them being in sync, and matching them makes the picture look mechanical. The AC was deliberately slowed from 8 to 6 fps after testing on the real display.

Because if consecutive source frames are byte-identical after palette reduction, img2webp collapses them into one frame with a longer duration. Eight frames where 2==3 and 6==7 becomes six frames with durations [125,125,250,125,125,250] β€” still 1000 ms total, identical motion on screen. This is not a bug; do not "fix" it.

Parse the WebP container's ANMF chunks. Do not trust an imaging library β€” Pillow reports duration: 0 on read even for correct files.

And use img2webp, not Pillow, to build them. Pillow's save_all also merges identical frames but rewrites the durations while doing it, which is easy to miss and changes the loop length.

  • pngquant silently flattens a multi-frame APNG to frame 1. Quantise individual framesbefore assembling, never after.
  • img2webp needs an explicit -lossy flag. A bare-q is silently ignored and you get a lossless file back.

It works β€” a sprite sheet with steps(N) and an animated background-position-x was built and verified pixel-exact β€” but it requires card-mod, because @keyframes are tree-scoped: a rule defined in the document does not reach an element inside a shadow root, and HA themes can only set CSS variables, not define rules. Since you are generating the assets anyway, the speed is baked in at encode time either way, so CSS buys almost nothing for a HACS dependency. Use animated WebP.

(If it ever is revived, the one rule that matters: the sheet must be N+1 columns for N frames. Percentage background-position resolves against (container width βˆ’ image width), so with N+1 columns steps(8) lands exactly on frames 0..7. A plain 8-column sheet lands on fractional frames and smears.)

As YAML-mode Lovelace: files on disk under /config, registered in configuration.yaml. Not through the UI.

lovelace:
  mode: storage          # keeps existing UI-editable dashboards working
  dashboards:
    floor-plan-yaml:     # the slug MUST contain a hyphen
      mode: yaml
      filename: floorplan/dashboard.yaml
      title: Floor Plan
      icon: mdi:home-group
      show_in_sidebar: true

Adding it needs one restart. After that, editing any of the files needs only a browser refresh β€” HA caches the dashboard on file mtime, so a newer file invalidates the cache by itself. No restart, no reload service.

Using a new slug means it runs side by side with anything that already exists. Always do that: confirm it works, then retire the old one.

/config/floorplan/
    dashboard.yaml            the views; hand-edited
    ground_elements.yaml      \
    upper_elements.yaml        }  the element lists, one per view; hand-edited
    garden_elements.yaml      /
    <anything>_elements.yaml     optional extra overlays

/config/www/floorplan/
    ground/  upper/  garden/     the served WebP, one folder per view

/config/www/ is served at /local/, which is what every image path in the cards refers to: /local/floorplan/ground/ground_day.webp.

Two reasons, and the first is the important one:

  1. Single source of truth. If a combined view is built later (Part 10), the same file is!include d byboth the standalone floor view and the combined view. A room is edited once and changes in both places. Verified: it is the same object in both.
  2. dashboard.yaml stays short enough to read.
- type: picture-elements
  image: /local/floorplan/ground/ground_day.webp
  elements: !include ground_elements.yaml

Do this from day one even if there is no combined view planned. It costs nothing.

Each floor is a panel view holding one card:

- title: Ground Floor
  path: ground-floor
  icon: mdi:home-floor-g
  type: panel
  background: "#1c1c1c"
  show_icon_and_title: true
  cards:
    - type: picture-elements
      image: /local/floorplan/ground/ground_day.webp
      elements: !include ground_elements.yaml

To make the card fill the panel while keeping its aspect ratio, add card-mod CSS using that view's crop dimensions:

      card_mod:
        style: |
          ha-card {
            width: min(100%, calc((100vh - 56px) * <cropW> / <cropH>));
            margin: 0 auto;
          }

Use 100vh and a literal 56px if a TV or older browser is a target; 100dvh with var(--header-height, 56px) is nicer but needs Chromium 108+.

The core pattern β€” one stock image element per device:

- type: image
  entity: climate.living_room_ac
  image: /local/floorplan/ground/ac_living_off.webp        # the still
  state_image:                                             # state -> animated file
    cool:      /local/floorplan/ground/ac_living_on.webp
    heat:      /local/floorplan/ground/ac_living_on.webp
    heat_cool: /local/floorplan/ground/ac_living_on.webp
    auto:      /local/floorplan/ground/ac_living_on.webp
    dry:       /local/floorplan/ground/ac_living_on.webp
    fan_only:  /local/floorplan/ground/ac_living_on.webp
  tap_action:
    action: more-info
  style:
    left: 31.80%
    top: 78.88%
    width: 12.824%
    z-index: '2'

Unlisted states (off, unavailable) fall through to image:, so they handle themselves.

Conventions that are not optional:

  • Never set height on an image element. It follows from the aspect ratio; setting it distorts the art.
  • z-index values arestrings in YAML ('2' ), because CSS wants a string.
  • Do not use YAML merge keys (<<: *anchor) inside element config. These files are parsed server-side by Python, but the config reaches a frontend that uses js-yaml. Plain anchors/aliases (&x /*x ) are fine in!include d files β€” the frontend only ever sees expanded JSON β€” but merge keys are not worth the risk.

Driven by one helper: input_boolean.dashboard_night (an automation on sun elevation sets it; a manual toggle is useful too).

A picture-elements card's own image: cannot be made conditional. So:

  • the day background stays as the card'simage:
  • the night background is painted as aconditional element atz-index: '0' ,left: 50% ,top: 50% ,width: 100% β€” covering the whole card
- type: conditional
  conditions:
    - entity: input_boolean.dashboard_night
      state: 'on'
  elements:
    - type: image
      image: /local/floorplan/ground/ground_night.webp
      tap_action: {action: none}
      style: {left: 50%, top: 50%, width: 100%, z-index: '0', pointer-events: none}

Because both backgrounds are the same union crop (Part 3), they are pixel-aligned and nothing shifts.

Each device then needs two conditional blocks β€” one for day, one for night β€” each holding one image element that uses state_image for its own on/off. Two dimensions, two mechanisms: conditional for day/night, state_image for entity state. state_image can only key off one entity, so it cannot carry both.

conditional ANDs its conditions, so combinations are flat blocks, not nesting. Day/night Γ— a mode toggle = four flat conditional blocks, each with both conditions listed. Verbose, but completely predictable β€” and it keeps every block's geometry identical so nothing can drift.

Place the 1Γ—1 transparent WebP, give it explicit width, height and display: block:

- type: image
  image: /local/floorplan/garden/ui_blank.webp
  entity: light.garden_lamps
  tap_action: {action: more-info}
  style:
    left: 32.37%
    top: 65.55%
    width: 70px
    height: 70px
    z-index: '8'
    display: block
    transform: translate(-50%, -20%)
    cursor: pointer

display: block is explicit because a custom element defaults to inline, where height is ignored. The transform shifts the box off dead centre β€” useful for a lamp where the hit area should sit on the head and hang down the post.

While positioning these, add a temporary background: rgba(255,0,0,.3) line to see the box, then comment it out. Leave the commented line in the file for next time.

Write every pixel length as:

width: calc(70 * var(--fp-u, 1px))

--fp-u is a project-defined unit. A standalone view sets nothing, so the 1px fallback applies and it behaves like plain pixels. A combined view (Part 10) sets --fp-u: 0.0521vw β€” exactly 1px at a 1920 viewport β€” and everything scales together. Costs nothing to adopt from the start and is painful to retrofit.

Leave border: hairlines in real px so they cannot vanish sub-pixel.

Write a linter and run it after every edit. Three passes are enough to catch nearly everything:

  1. YAML syntax , with line and column. (Ruby's Psych parses HA's custom tags β€”!include ,!secret β€” without complaint, because it stops at the AST. Handy if pyyaml is not available.)
  2. Element structure : every element has atype ; everyconditional has bothconditions andelements ; everystate_image has anentity ; everytap_action /hold_action /double_tap_action names a real action.
  3. Image references : every/local/floorplan/... string resolves to a file that actually exists on disk. Strip cache-busting suffixes (?v=2 ) first.

Only check nodes reached through an elements: list against the element vocabulary β€” card configs (views:/ cards:, and any popup content) use a different type vocabulary and will otherwise produce pages of false warnings.

By hand, with a drag tool. There is no automatic way, and it is worth knowing why: the backgrounds do not contain the devices. A device layer is a separate drawing with its own drop shadow on transparency, so there is nothing in the background to match it against. Template matching was tried and lands on blank wall. Do not spend time on it.

The workaround is a self-contained HTML page: the background as an <img>, each device as a draggable <img> on top, and a readout of each one's left / top / width as percentages of the view crop. Generate it (embed the images as data URIs so it opens straight from disk with no server), drag, copy the numbers into the YAML.

Regenerate the placer whenever the crop box or the device set changes.

left and top are the centre of the element, as a percentage of the card. width is a percentage of the card width. Height is never set.

To convert a device's width in master-canvas pixels to a card percentage:

width% = frame_width_px / cropW Γ— 100

A useful trick when art has a lit and an unlit render of the same scene: difference the two images and take the centroid of each bright blob. The emitters are the only compact bright deltas, so this locates every lamp exactly. It is how a reference build found four lamp posts in a garden. Reusable any time there is an on/off pair.

Keep a table of master-canvas pixel centres per device, alongside the percentages. If the crop box ever changes, recompute percentages from the pixels. Never convert percentage to percentage between two different crops β€” that is where the errors come from.

Rough convention that works:

layer z-index
night / scene background overlays '0'
device artwork '2' –'5' , in painter's order (further from viewer = lower)
invisible tap zones '8'
status pills and their contents '11'

Give each device a distinct value in the order they should occlude each other.

card where stock?
picture-elements every view β€” the whole dashboard stock
vertical-stack only the combined view (Part 10), as an absolute-positioning host stock
tile (withfeatures: ) inside popups β€” big touch targets stock
history-graph inside popups stock
markdown inside popups, for templated detail stock
heading (with badges) an alternative status strip above the floorplan stock
card-mod the combined view; optional elsewhere HACS
browser_mod popups that control more than one entity HACS
element used for
image every piece of device artwork, every background overlay, every tap zone
conditional day/night, per-state art, and β€” importantly β€” as a flex container (Part 9)
icon glyphs in the status pills
state-label sensor readouts in the status pills
state-icon avoid where possible β€” see Part 9

Generic shapes β€” substitute the user's real IDs, and read them off their live system, never invent them:

purpose entity kind example placeholder
day/night switch input_boolean input_boolean.dashboard_night
air conditioning climate climate.<room>_ac
room temperature sensor (device_class temperature) sensor.<room>_temperature
room humidity sensor (device_class humidity) sensor.<room>_humidity
air quality sensor sensor.<room>_carbon_dioxide
TV / media media_player media_player.<name>
lights light light.<area>
a decorative/manual state with no real device input_boolean input_boolean.dashboard_<thing>_on
a rare alternate artwork mode input_boolean input_boolean.dashboard_<mode>

That last-but-one row matters: something drawn on the floorplan does not need a real device behind it. A wood stove with no sensor is perfectly well represented by an input_boolean the user flips when they light it.

domain states to list in state_image
climate every active mode: cool ,heat ,heat_cool ,auto ,dry ,fan_only
switch ,light ,input_boolean "on" (quote it β€” bareon is YAMLtrue )
media_player playing , and usuallyon too

Everything unlisted falls through to image:, which handles off, unavailable and unknown for free.

tap_action: {action: more-info} β€” HA's own dialog for that entity. It is the right answer nearly always: full controls, no design work, consistent everywhere.

Two exceptions:

  • Purely decorative art (a background overlay):tap_action: {action: none} pluspointer-events: none , so it never eats a tap meant for something beneath it.
  • One tap needs to control two or more entities.more-info takes exactlyone entity. Options, in the order worth offering:
    1. A subview with a normal card β€” fully stock, but it is a page change (awkward with a TV remote).
    2. A Browser Mod popup β€” best UX, first HACS dependency.
    3. Tap and hold for two different dialogs β€” stock, but undiscoverable.
    4. A group helper β€” its dialog leads with the group toggle, usually the wrong shape.
  • A

Browser Mod 2 style β€” fire-dom-event, not call-service / perform-action:

tap_action:
  action: fire-dom-event
  browser_mod:
    service: browser_mod.popup
    data:
      title: Garden watering
      dismissable: true
      timeout: 120000          # auto-close if left open
      initial_style: wide
      content:
        type: vertical-stack
        cards:
          - type: tile
            entity: valve.<something>
            features: [{type: toggle}]
          - type: history-graph
            hours_to_show: 24
            entities: [{entity: sensor.<something>}]
      right_button: Start now
      right_button_variant: success
      right_button_close: true
      right_button_action:
        action: perform-action
        perform_action: switch.turn_on
        target: {entity_id: switch.<something>}
      left_button: Close
      left_button_close: true

Put the same popup on every element that should open it (a conditional element cannot carry a tap_action itself, so it goes on the icons inside). If the block is repeated many times, a plain YAML anchor deduplicates it safely β€” these files are resolved server-side.

Yes, and it is a legitimate alternative design to the in-picture pills:

  • A heading card directly above the floorplan, whose badges lay out in a real horizontal row. Each badge takes avisibility: block, and a badge whose condition is false is removed and the row closes up. (Badgevisibility: works but is undocumented.) The view must betype: sections , notpanel , for this.
  • View-level badges β€” the only ones that expose a size variable (--ha-badge-size , default 36px).

The size difference is worth knowing: heading-badge icons are hard-coded at 14px with no var(), so no theme can enlarge them; only card-mod can. If something large is wanted, use view-level badges. If in-picture pills are wanted, read Part 9.

Yes β€” with one specific trick, and it is the most useful discovery in this whole project.

The general rule is that it cannot: every element the card renders gets a .element class carrying position: absolute; transform: translate(-50%,-50%), so elements never affect one another. Hide one and you get a hole.

But conditional elements are deliberately exempted from that class. The frontend excludes HUI-CONDITIONAL-ELEMENT on purpose, to keep it a transparent, static container that appends children into itself. And style: is applied as inline styles to every element, the conditional included β€” and inline beats a class.

Therefore: style a conditional as an absolutely-positioned inline-flex box, and set position: static; transform: none on each of its children. They flow left to right, the container auto-sizes, and a slot that vanishes takes its width with it.

- type: conditional
  conditions: []              # always true: checkConditionsMet runs conditions.every()
  style:
    position: absolute
    left: calc(29.5% - 12 * var(--fp-u, 1px))
    top: calc(66.28% + 0px)
    transform: translate(0, -50%)
    display: inline-flex
    align-items: center
    height: calc(34 * var(--fp-u, 1px))
    padding: 0 calc(11 * var(--fp-u, 1px))
    box-sizing: border-box
    background: rgba(60, 64, 72, 0.35)
    border-radius: calc(17 * var(--fp-u, 1px))
    backdrop-filter: blur(calc(3 * var(--fp-u, 1px)))
    border: 1px solid rgba(255, 255, 255, 0.14)
    z-index: '11'
    color: '#bbbbbb'                                 # inherited by every slot
    '--mdc-icon-size': calc(22 * var(--fp-u, 1px))   # ditto
    pointer-events: none
  elements:
    - type: icon
      icon: mdi:thermometer
      tap_action: {action: more-info, entity: sensor.<room>_temperature}
      style:
        position: static
        transform: none
        display: flex
        align-items: center
        pointer-events: auto
    - type: state-label
      entity: sensor.<room>_temperature
      style:
        position: static
        transform: none
        margin-left: calc(-2 * var(--fp-u, 1px))
        color: '#ffffff'
        font-size: calc(15 * var(--fp-u, 1px))
        font-weight: '600'
        white-space: nowrap
        line-height: '1'

The container is the pill β€” it sizes itself, so there is no separate background element and no second width for the "something is hidden" case.

  • Spacing goes on each slot's LEADING element, never as flex gap on the container. Aconditional element whose conditions fail doesnot getdisplay: none (that is the conditionalcard , a different component). It merely empties itself and stays in the DOM as azero-width flex item . Agap would still be painted around it and leave exactly the hole this design removes. (gap is also Chromium 84+, unsafe on a TV.)
  • Use type: icon, not type: state-icon, for slot glyphs. state-icon renders astate-badge whose shadow root hard-codeswidth: 40px; height: 40px on:host with novar() β€” unreachable, and in flow layout it sets the slot width.type: icon renders a bareha-icon sized by--mdc-icon-size .tap_action: {action: more-info, entity: X} still works on it, because the action handler checksactionConfig.entity beforeconfig.entity .
  • pointer-events: none on the container, auto on the interactive children.
  • Anything that can come and go goes in a nestedconditional withdisplay: inline-flex; align-items: center , carrying its margin on the elementinside it.

Because align-items: center centres each child's box, and an icon's box is not its glyph. The icon host is display: block with an inherited line-height, so its line box carries a baseline strut: a 22px glyph sits at the top of a ~29px box with the descender space hanging below. Centring the box puts the glyph ~3.5px high.

Fix, on every flow child:

    - type: icon
      style:
        display: flex           # collapse the host box onto the glyph
        align-items: center
    - type: state-label
      style:
        line-height: '1'        # 40px box -> 31px, stops overflowing the pill

Measure this against the running dashboard, not a mock. A hand-built HTML mock with <span> placeholders has no line-height strut and will report everything as perfect. A mock is reliable for widths, reflow and collapse-to-zero β€” all container behaviour β€” and blind to anything that depends on what a real HA element puts in its own box.

10u between slots, βˆ’2u between an icon and its own value (where u is var(--fp-u, 1px)).

The negative is not a mistake. An MDI glyph does not fill its --mdc-icon-size box β€” it carries several px of side bearing inside the SVG, so the gap you see is the CSS margin plus that bearing. Going from 4u to 2u looks like almost no change for exactly that reason. βˆ’2u eats into the bearing and lands the value where it looks right.

Do not push much further: bearing varies per glyph, so a large negative that looks fine on one slot crowds another. βˆ’4u is about the limit. A first pass at 14u between slots read as loose; check on the real display before raising it.

Because state-label renders:

prefix + (config.attribute ? stateObj.attributes[config.attribute]
                           : hass.formatEntityState(stateObj)) + suffix

Only the no-attribute branch goes through formatEntityState, which is what appends the unit. Read an attribute (attribute: temperature on a climate entity) and you get the bare JS value β€” 22, not 22 Β°C.

Fix with the element's own suffix: ' Β°C'. Consequence: the suffix is hard-coded and does not follow HA's unit system, so an instance that switches to imperial needs those labels edited.

Badges behave differently and are not a counter-example β€” state_content: runs through the attribute-formatting pipeline and appends the unit itself. Same data, different renderer. Never assume behaviour transfers between badges and picture-elements.

If you used type: state-icon, it renders a <state-badge> whose shadow root sets, on its own :host:

:host { color: var(--paper-item-icon-color, #44739e); }

That is an explicit declaration on the element itself, so it beats any color inherited from the wrapper. The glyph stays HA's default blue-grey and your color: looks completely ignored.

Custom properties do cross shadow boundaries, so set the property instead:

wrapper sets resulting glyph colour
color: #fff #44739e β€” ignored
--paper-item-icon-color: #fff white

Set --paper-item-icon-color, --state-icon-color and color together, plus state_color: false so HA does not recolour by entity state. (With type: icon this does not arise β€” plain color: works.)

The general rule: when something inside an HA component ignores your style, check whether the component exposes a custom property for it. If it does, set the property. If the value is a bare literal with no var(), only card-mod can win.

One % anchor per room, offsets from it in scalable units. The anchor tracks the artwork as the card resizes; the contents keep their shape and stay legible instead of shrinking. To move a whole panel, change its two anchor numbers and nothing else.

Keep panels horizontal β€” one row: 🌑 21.4 Β°C πŸ’§ 48 % ❄ 22 Β°C. A vertical stack of icon/value pairs was tried and rejected.

Sensors and a mode glyph. Not toggles β€” tapping any glyph opens more-info, which has the controls. A workable set per room: temperature, humidity, optionally air quality, and the climate mode icon plus setpoint.

For climate, use hvac_mode (the entity state), not hvac_action. hvac_action tracks what the compressor is doing moment to moment, so the icon flickers between cooling and idle as the unit cycles. hvac_mode is what the user set, so it stays put β€” and being the entity state rather than an attribute, the conditions are plain entity + state.

Core ships no per-mode climate icons, so pick a vocabulary and reuse it: cool ❄ mdi:snowflake Β· heat πŸ”₯ mdi:fire Β· heat_cool mdi:sun-snowflake-variant Β· auto mdi:thermostat-auto Β· dry mdi:water-percent Β· fan_only mdi:fan Β· off mdi:fan-off.

The setpoint slot should be conditional on state_not: 'off' β€” it is the slot that comes and goes, and with the auto-fit container its absence simply shrinks the pill.

Skip this section unless the user specifically wants every floor on one screen. It is the most complex part of the project and it needs card-mod.

No. picture-elements cannot host cards. Verified in-browser: hui-element is not a registered custom element ("Custom element doesn't exist"), and hui-card is registered but has no setConfig, which is what the element factory calls ("r.setConfig is not a function"). Do not retry that route.

A vertical-stack whose children are absolutely positioned by card-mod over a transparent spacer:

  • Child 1 is a fully transparent PNG the exact size of the union of all floors, left in normal flow. It gives the stack's#root the right aspect ratio, so the percentagetop: values below have a height to resolve against.
  • Children 2..N are the floor cards, absolutely positioned on top, each with az-index .
- type: vertical-stack
  card_mod:
    style: |
      #root { position: relative; overflow: hidden; }
      #root > hui-card:nth-child(2) { position: absolute; left: 0%;      top: 31.26%; width: 57.84%; z-index: 2; }
      #root > hui-card:nth-child(3) { position: absolute; left: 3.97%;   top: -8.96%; width: 48.24%; z-index: 3; }
      #root > hui-card:nth-child(4) { position: absolute; left: 44.48%;  top: 18.90%; width: 55.52%; z-index: 1; }
  cards:
    - ...

Each nested card gets card-mod making it transparent and click-through:

      card_mod:
        style: |
          ha-card {
            --fp-u: 0.0521vw;
            --ha-card-background: transparent;
            --ha-card-border-width: 0;
            --ha-card-box-shadow: none;
            pointer-events: none;
          }
          ha-card * { pointer-events: auto; }

pointer-events: none on the card with auto on its children lets clicks fall through the transparent regions to the floor beneath.

The plain string form (card_mod: style: |), which injects into the stack's own shadow root where #root matches. The hui-vertical-stack-card $: key form does not work β€” tested, no effect.

gap: 0 is unnecessary: absolutely positioned children leave flex layout, so with one in-flow spacer there are no gaps.

From three constants, in a script β€” never by hand, and never let the spacer image and the percentages be edited independently:

constant meaning
CANVAS_BOX each view's crop box on the master canvas
UPPER_LIFT how far to raise an upper floor for an exploded look
TOP_CROP dead space to trim off the top of the union

The script derives the union canvas, every card placement percentage, the stack CSS and the transparent spacer image, so the image and the config can never disagree. Reference this back to the spacer through a self-versioning URL (?v=<width>x<height>) so a stale copy can never be served β€” and use the same URL on every card that references it, or an overlay will position against the wrong height.

Have the script patch only the geometry lines in place and print a diff. It must never rewrite the file wholesale (Part 11).

Lower floors go on top of outdoor areas; upper floors go on top of lower floors. A garden belongs at the bottom β€” put it on top and its furniture draws straight across the house interior.

Judge overlap by counting opaque pixels in the intersection, never by a whole-image mean pixel difference. A mean over the full frame averages large localised differences away β€” that is precisely how a reference build once concluded the z-order "barely mattered" and stacked a garden over a house.

Enough for the exploded look, not enough to shrink everything. Zero overlap in the reference build needed 685px and cost another 8% of display width; 435px left 30% of the original overlap, confined to a rear wall and roof, which reads naturally. There is a real trade-off: the taller the union, the more a 16:9 screen fits it by height and the smaller everything renders. Build a drag tool that shows live overlap, the resulting union aspect, and the cost in display width, and let the user choose.

Add another picture-elements card to the stack, using the same transparent spacer as its image, with its own !include d elements file, positioned at left: 0; top: 0; width: 100% and a high z-index.

Its pointer-events: none must be cleared on the HOST, in the stack's own CSS, not just inside the card:

#root > hui-card:nth-child(5) { position: absolute; left: 0; top: 0; width: 100%; z-index: 4; pointer-events: none; }

card-mod can only reach inside a card's shadow root, so ha-card rules leave the hui-card host itself hittable and the overlay still swallows every click on the whole dashboard.

And inside the overlay card, do not write a blanket ha-card * { pointer-events: auto } β€” the container div spans the whole canvas, so that makes the entire dashboard unclickable. Set ha-card div, ha-card hui-image { pointer-events: none; } and have each genuinely interactive element opt back in with its own inline pointer-events: auto.

Place such an overlay in an area no floor card occupies. Work out where that is from the geometry, do not guess.

The modern one β€” condition: state, condition: numeric_state, condition: or β€” on frontend β‰₯ 2024.2. hui-conditional-element and hui-conditional-card share HuiConditionalBase, so the picture-elements element gets the same checkConditionsMet the card does. Both schemas can coexist in one file, because checkConditionsMet falls through to a state check for any condition with no condition: key.

Two traps if you use numeric_state:

  • below is EXCLUSIVE. With an integer 0–10 sensor, a red block atabove: 8 and an amber guard atbelow: 8 leaves the value 8 matchingneither β€” no badge at all, at the one value most likely to be sitting there. Usebelow: 9 .
  • numeric_state is FALSE for a non-numeric state. Anunavailable sensor makes everynumeric_state on it false,including a negated guard . Add astate: [unavailable, unknown] escape hatch OR'd beside it.

If two badges are mutually exclusive, make them actually mutually exclusive rather than stacking them by z-index. Two identical glyphs layered will leak the lower one through the upper one's antialiased edges and double the drop shadow.

Regenerating a file wholesale from a generator and overwriting it. In the reference project this destroyed real work twice: once clobbering hand-entered entity IDs, once discarding hand edits to a deployed dashboard file with no backup.

The rule: read what is on disk, make the targeted edit, diff, then write. Assume every file may have been edited by the user outside your session since it was last generated. If a generator is involved, have it patch specific lines in place and print a diff of exactly what it touched.

Run git status before any generator. If a file is already dirty, read the diff first β€” that is how a hand edit gets silently reverted.

Also: never read a file back after an overwrite and report that nothing was lost. That compounds the error into a false all-clear.

Decide this explicitly, write it down at the top of the repo, and keep it true:

file status
*_elements.yaml SOURCE β€” hand-edited, nothing generates them
dashboard.yaml SOURCE β€” hand-edited; a script may patch only its geometry lines
the transparent spacer PNG derived
the drag placer HTML derived

The one thing worse than not having this table is having one that is out of date.

Three attempts, maximum. Then stop, and tell the user what the check says, what you changed, and what you think is actually wrong. A fourth guess is worth less than five seconds of the user looking at it.

Never commit or deploy while a check is failing β€” not the broken file, not "the rest of the work" alongside it, not with --no-verify.

No β€” the visual editor corrupts them. HA's image picker rewrites a plain image: string into an object:

image:
  media_content_id: /local/floorplan/ground/ground_day.webp
  media_content_type: ''
  metadata: {...}

hui-image expects a string and sets <img src> from it directly, so an object renders nothing β€” silently, with no console error and no log line. Seen in the wild on a background and on two device layers after one GUI edit. The GUI editor also strips all comments.

Keep image: and every value inside state_image: a plain /local/... string, and use the raw configuration editor, never the per-card visual editor.

Check the browser console for themesReady: Timeout waiting for themes to become ready. card-mod 4.x gates all styling behind themes being loaded, and on an instance with zero custom themes defined it can time out and silently do nothing. Defining any theme at all resolves it.

You cropped day and night to their own bounding boxes instead of the union. See Part 3.

The off still and the on animation have different canvas sizes. See Part 4.

If it has already happened and re-exporting is not an option: recover the offset by masked template matching of the still into frame 0 (cv2.matchTemplate with TM_SQDIFF and the alpha as mask; cross-check with an alpha-silhouette TM_CCORR_NORMED β€” both should agree), then pad the still onto the animation's canvas at that offset. Then recompute the element so nothing moves on screen: new canvas top-left = old still top-left βˆ’ (dx,dy); new centre = that + canvas/2. Verify the padded still lands pixel-identically to the old one.

  • Deploy the files you mean, not the whole directory. The copies on the HA box can carry hand edits. Wrapscp in a script that refuses anything outside the intended source folder and destination, and offer a--dry-run .
  • If the deploy target runs OpenSSH β‰₯ 9, scp speaks SFTP and doesnot shell-expand the remote path. Quoting it puts literal quotes in the filename and producesremote mkdir "'/config/floorplan/'": No such file or directory β€” which reads like a connectivity error and is not one. Pass the remote path unquoted.
  • Deploying does not lint. A failing check means do not push that file.

Two asymmetries worth knowing:

  • CSS custom properties DO cross shadow boundaries , so a plain HA theme can restyle text inside every card with no HACS.
  • @keyframes do NOT cross β€” they are tree-scoped, so a rule defined in the document never reaches an element inside a shadow root. That asymmetry is exactly why a CSS-animation approach needs card-mod and an image-based one does not.

Theme variables are written without the leading -- in a theme file.

Use these to size the job. The answers change the artwork brief, which is the expensive part.

  1. How many views? One area, one floor, the whole house?

  2. Which devices should be visible and animated? Get a list. Four to eight per view.

  3. Which of those are real entities, and which are just things they want to see? (The second kind gets aninput_boolean .)

  4. Day/night? Doubles the art. Almost always worth it, but it is their call.

  5. Any whole-scene lighting state beyond day/night?

  6. What screen is this for? TV, wall tablet, desktop, phone. This decides the CSS conservatism and the aspect ratio.

  7. Do they want all floors on one screen? If yes, Part 10, and it needs card-mod.

  8. Are they willing to install HACS components? Establish the line early.

  9. Where does the artwork come from? If there is no answer here, stop and solve that first.

  10. Which sensors already exist in HA? Read the real entity IDs from their system. Never invent them, never guess a naming pattern.

  11. Settle the artwork brief. Master canvas size, view list, device list, states per device, day/night. Give the illustrator the full-canvas rule in writing.

  12. Get one view's art only β€” one floor, day and night, one device. Prove the whole pipeline end to end before commissioning the rest.

  13. Crop to unions, record crop boxes in a_meta.json per view.

  14. Encode : backgrounds lossy q88; stills and frames pngquant-then-lossless; build the animated WebP withimg2webp and verify theANMF durations.

  15. Copy to /config/www/floorplan/<view>/ , register the YAML dashboard, restart once. Confirm the background alone renders.

  16. Build the drag placer for that view; place the devices; paste the percentages.

  17. Add day/night : the helper, the conditional background overlay, then two conditional blocks per device.

  18. Add tap actions (more-info almost everywhere).

  19. Add one status panel , using the auto-fit container recipe. Measure the icon alignmenton the running dashboard , not in a mock.

  20. Show the user. Iterate on spacing and animation speed on thereal display.

  21. Repeat for the remaining views.

  22. Only then , if wanted, build the combined view.

  23. Write a linter and run it on every edit. Write down what is source and what is derived.

  • The master canvas size, and each view's crop box and origin.
  • Each device's centre in master-canvas pixels , not only percentages.
  • The frame count and per-frame duration for every animation, and why it was chosen.
  • The real entity IDs, marked as read from the live system.
  • Every trap you hit, with the symptom that led to it. The symptom is the part that makes the note findable later β€” "the icons are glued to the top of the pill" is worth more than "line-height strut affects flex centring".

End of handover. If something in here contradicts what the running dashboard actually does, the running dashboard is right β€” go and measure it.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @home assistant 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/handover-for-ha-dash…] indexed:0 read:43min 2026-09-08 Β· β€”