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-elementsis 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-elementsconditionals 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:
- The floors line up automatically. Compositing all floors into one image later becomes a plain alpha stack β no registration work, no guessing.
- Device layers land themselves. If a device is exported full-canvas, its position is already known; nobody has to drag it into place.
- 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).
- One isometric cutaway per floor/area, day andnight versions, on the master canvas.
- For any whole-scene lighting state (garden lamps on, for instance): an additional full render.
- For every animated device: a still "off" frame and an8-frame loop for "on", inboth day and night lighting, each on the master canvas.
- Device layers with their own drop shadows, on transparency.
- 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 of
left/top/widthserve both. Assert it at build time. - The off still and the on animation must share one canvas size.
state_imageswaps the file but the element keeps onewidthand 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/_onfor day and_night_off/_night_onfor 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 has
cool,heat,heat_cool,auto,dry,fan_onlyandoff. If the AC looks the same whenever it runs, map all six active modes to thesame_onfile. 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 separate
input_booleanand drawn as extraconditionalblocks, so it does not multiply the normal art. Same geometry, different file, so no coordinates change.
Two different mechanisms; pick per light:
- 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 at
z-index: 0. One extra render per state. - 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.
pngquantsilently flattens a multi-frame APNG to frame 1. Quantise individual framesbefore assembling, never after.img2webpneeds an explicit-lossyflag. A bare-qis 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:
- Single source of truth. If a combined view is built later (Part 10), the same
file is
!included 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. dashboard.yamlstays 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
heighton an image element. It follows from the aspect ratio; setting it distorts the art. z-indexvalues 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!included 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's
image: - the night background is painted as a
conditionalelement 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:
- 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.) - Element structure : every element has a
type; everyconditionalhas bothconditionsandelements; everystate_imagehas anentity; everytap_action/hold_action/double_tap_actionnames a real action. - 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-infotakes exactlyone entity. Options, in the order worth offering:- A subview with a normal card β fully stock, but it is a page change (awkward with a TV remote).
- A Browser Mod popup β best UX, first HACS dependency.
- Tap and hold for two different dialogs β stock, but undiscoverable.
- 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
headingcard 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
gapon the container. Aconditionalelement 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 . Agapwould still be painted around it and leave exactly the hole this design removes. (gapis also Chromium 84+, unsafe on a TV.) - Use
type: icon, nottype: state-icon, for slot glyphs.state-iconrenders astate-badgewhose shadow root hard-codeswidth: 40px; height: 40pxon:hostwith novar()β unreachable, and in flow layout it sets the slot width.type: iconrenders a bareha-iconsized by--mdc-icon-size.tap_action: {action: more-info, entity: X}still works on it, because the action handler checksactionConfig.entitybeforeconfig.entity. pointer-events: noneon the container,autoon the interactive children.- Anything that can come and go goes in a nested
conditionalwithdisplay: 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
#rootthe 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 a
z-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:
belowis EXCLUSIVE. With an integer 0β10 sensor, a red block atabove: 8and an amber guard atbelow: 8leaves the value 8 matchingneither β no badge at all, at the one value most likely to be sitting there. Usebelow: 9.numeric_stateis FALSE for a non-numeric state. Anunavailablesensor makes everynumeric_stateon 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. Wrap
scpin a script that refuses anything outside the intended source folder and destination, and offer a--dry-run. - If the deploy target runs OpenSSH β₯ 9,
scpspeaks 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.
@keyframesdo 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.
-
How many views? One area, one floor, the whole house?
-
Which devices should be visible and animated? Get a list. Four to eight per view.
-
Which of those are real entities, and which are just things they want to see? (The second kind gets an
input_boolean.) -
Day/night? Doubles the art. Almost always worth it, but it is their call.
-
Any whole-scene lighting state beyond day/night?
-
What screen is this for? TV, wall tablet, desktop, phone. This decides the CSS conservatism and the aspect ratio.
-
Do they want all floors on one screen? If yes, Part 10, and it needs card-mod.
-
Are they willing to install HACS components? Establish the line early.
-
Where does the artwork come from? If there is no answer here, stop and solve that first.
-
Which sensors already exist in HA? Read the real entity IDs from their system. Never invent them, never guess a naming pattern.
-
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.
-
Get one view's art only β one floor, day and night, one device. Prove the whole pipeline end to end before commissioning the rest.
-
Crop to unions, record crop boxes in a
_meta.jsonper view. -
Encode : backgrounds lossy q88; stills and frames pngquant-then-lossless; build the animated WebP with
img2webpand verify theANMFdurations. -
Copy to
/config/www/floorplan/<view>/, register the YAML dashboard, restart once. Confirm the background alone renders. -
Build the drag placer for that view; place the devices; paste the percentages.
-
Add day/night : the helper, the conditional background overlay, then two conditional blocks per device.
-
Add tap actions (
more-infoalmost everywhere). -
Add one status panel , using the auto-fit container recipe. Measure the icon alignmenton the running dashboard , not in a mock.
-
Show the user. Iterate on spacing and animation speed on thereal display.
-
Repeat for the remaining views.
-
Only then , if wanted, build the combined view.
-
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.