Skip to content

How the cards work

github-actions[bot] edited this page Oct 13, 2026 · 7 revisions

This page is made from docs/cards.md on every change to main; edit it there.

How the cards work

The runtime behaviour of the custom elements in src/cards/. For every key they take, see Writing the cards; for what they look like, Design; for the data three of them read, Data.

The cards are Lit elements, as Home Assistant's frontend is. Lit is their one dependency, bundled with them. Each card draws its DOM as an html template; what several cards share, and the pieces a large card's file is split into (the pop-ups card's popup-unfold.ts and popup-swipe.ts), are in src/cards/parts/; what reads or draws Home Assistant's own pieces is in src/ha/.

The cards

Card Shows
mnml-tile-card A room, person, car or infrastructure system: a name and icon, or an entity to take them from (the name its device's with label), state, chips, an item row; opens popup. A Batteries chip is a status chip whose below rules turn it orange under the room's low and red under its critical (20 % and 5 % by default). The item row is the item minified (itemLine() in parts/item.ts): one line with its icon, its name and its state, and every control of the item at a smaller size, a slider opening over the row; its pop-up's item, drawn from the same configuration, is drawn full size (Design)
mnml-entity-card An item: the entity's name (its device's with label) and state icon, a state line, controls, a popup; nested items collapse
mnml-header-card A pop-up header: a name and icon, or the entity's (the name its device's with label), the entity's state and controls, back and close buttons
mnml-heading-card A section heading: title and icon, with a state line and controls at the right
mnml-list-card Rows under a title: bars, thresholds under (low, critical) and over (high, critical_high), a share of another entity, relative times, words, flags, a daily total read as zero while it has no value (zero_when_empty), or a table with headers. lowest_first orders the rows by value; fold folds the list to its heading until a row is orange or red; summary puts the lowest or highest value, or a column's sum, in the heading
mnml-clients-card The connected clients in the entity's data (or the list attribute names; fields maps another source's field names, see Data), grouped by the networks' subnets in their order, then Other: the connection type's icon, the name with its FQDN from the optional names sensor's reverse lookups and hosts file, and the IP. With fold, each network folds to its heading and count, and Other stays open
mnml-glances-card A one-line strip of overall statuses, each a pill that sits straight on the page, with no card behind them: an entity's icon, name and state, or a count of the entities that need you, drawn only while they do
mnml-report-card The lines of the entity's details attribute, each entry with its details under it, grouped under the headlines (colors picks a group's colour, red otherwise), and their count in the heading; nothing while there are none, see Data
mnml-weather-card The weather entity's conditions now (temperature, state, and the humidity, wind, pressure and others it reports) and its forecast, followed over Home Assistant's weather/subscribe_forecast: the days when the entity has a daily forecast, else the half-days of a twice-daily one, else the hours of an hourly one
mnml-agenda-card The sources' calendar events, from today on, under week headings ("This week", "Next week", "Week of 19 Oct"), only for weeks that have something: it starts with the first, Next adds the next one below however far ahead and scrolls it into view with the button under it (the week's own start when both do not fit); a Next that finds nothing scrolls the buttons into view under the note, and Back to now returns to the first. A Next that finds nothing within a year says "Nothing until" that date, and the next Next searches the year after. An episodes source splits Sonarr's "Series - S01E04 - Title" into the series and "S01E04 • Title", and folds a series' episodes on one day into one row; a movies source shows the title. Each row has its kind's icon and the day. A calendar unavailable, or failing to answer, is named in orange
mnml-messages-card The messages in the entity's messages attribute, newest first, one item card each: an envelope coloured by the severity (red for error, orange for warning, blue for notice and info, grey for any other), the title, the source named by sources and the relative time, the text collapsed until the row is tapped, and a clear button that runs the clear script
mnml-button-card A button that calls service on entity
mnml-select-card A dropdown over a select entity, a climate attribute (hvac_modes, preset_modes, fan_modes, swing_modes, swing_horizontal_modes) or scenes, checking the one active_scene names
mnml-slider-card A slider: a light's brightness, white temperature or hue, a climate target, a number, a volume, a cover's position, a fan's speed, a humidifier's target
mnml-media-card A media player: artwork, title, transport controls, volume, on / off; opens popup
mnml-car-plan-card The car from above: doors, hood, tailgate, windows, sunroof, tyres with tyre_targets, tyre_low_share and tyre_warn_share. A part reads as open while its state is one of open_states (default on, open) and half open while it is one of half_states (default intermediate)
mnml-template-card A template expanded in place, the home's own or MNML's with the home's changes, filled by area or by hand (Templates)
mnml-popups-card The shell holding every pop-up, opened by the URL hash

A control in chips or controls is one of the following:

Control Is
toggle A toggling button; primary: true fills it
slider A button that opens a slider over the card
select An icon that opens a mode menu; primary: true fills it (the AC's mode button)
service A button that calls a service
nav A coloured icon that opens a pop-up
indicator A coloured icon, titled with its entity's name and state
status A named icon, coloured by the first of its rules that any of its entities matches
tyres The tyre icon: orange while a tyre drops, red while one is low
scenes An icon that opens the room's scenes, the active one checked
  • Visibility and colour. show hides a control by a state rule, and when decides its colour. A rule's is / not lists match the state lower-cased with hyphens removed, and a number as a number (10000.0 is 10000).
  • Unavailable devices. An unavailable entity keeps its place and turns orange: a state prints as an orange "—", a row or chip colours, and a control that acts on it (toggle, slider, select, service) is drawn as an inert orange display rather than a button that cannot work. An entity with no value at all (unknown) is left out. A list row with zero_when_empty is the exception to both: it reads as 0 in its unit, uncoloured. A status rule whose is names unavailable colours an unavailable entity with the rule's colour instead.
  • State lines. An entry of a state line is an entity's state, an attribute, a literal text, the minutes of a seconds counter, the serial number of the entity's device, or the entity's name. icon: true puts the entity's icon in front of it.

Writing the cards lists every key, and src/contract/cards.ts declares them.

Lifecycle of a card

Every card but the pop-up shell and the template card extends MnmlCard<C> (base.ts), a LitElement. Each card is a shadow root holding its styles and what its draw(hass, config) returns. Each card is the Host it gives every part it draws: the current hass, whether it is on the page (isConnected), hold(), register(), notify(), which shows a message as Home Assistant's own toast, and refresh(), which draws the card again.

Step What happens
setConfig() Checks the keys against the card's schema, then runs its validate(), which throws for a missing required key or a value outside its list (requireString(), requireOneOf() and the rest in base.ts); either way HA shows an error card naming it. It drains the holds and teardowns, stores the configuration, collects every entity id in it (any string shaped domain.object_id, except a service, which has the same shape), and asks for an update
set hass Asks for an update only when one of those state objects, hass.entities, hass.devices, hass.locale, hass.config or one of HA's formatters (localize, formatEntityState, formatEntityAttributeValue) is a new object, as HA's own cards do, so a new language or unit system draws every card again at once rather than entity by entity. While a hold is open no update runs (shouldUpdate()), and the last release asks for one
draw() Returns the card as an html template, which Lit lays over the DOM already there, so an element that stays keeps its keyboard focus. It may return undefined: the card then hides its own element, so an empty card takes no space and no gap in a pop-up's column
disconnected disconnectedCallback() drains the holds and teardowns. A card taken down by a closing pop-up keeps its configuration and its DOM, and comes back live: connectedCallback() draws it again on its return, and so does each hass after that which changes what it watches

State reaches a card through set hass only.

Holds and teardowns

An interaction that a state update must not interrupt holds the card (host.hold()):

  • A slider, for the drag, and until 600 ms after its service call has actually landed. A 5 s ceiling keeps a hung call from holding the card for ever. Only the last value sent lets go: a key pressed while an earlier value is on its way waits out its own pause and is sent, and the earlier call's landing neither cancels that pause nor releases the card while a key or a drag is pending. The key pause and the release are two timers (pause, letGo).
  • An overlay, or a menu, while it is open. Both open through dismissable() (parts/overlay.ts), which holds the card, registers the teardown, and closes on a tap outside or on Escape, and a menu on a scroll outside too. It puts focus back on the trigger before it releases the card, so the update that follows leaves focus there.

A press is not a hold. The button card's 600 ms flash is a state of the element (pressed), so the state that the press changes draws at once and the flash carries over.

The hold machinery is built so that it cannot get stuck:

  • A hold releases once, however often its release is called.
  • A drag settles on lostpointercapture as well as on pointerup and pointercancel. So removing the track mid-drag cannot leave a hold open.
  • register(teardown) records what to undo: the open menu, the open overlay, the weather card's forecast subscription, a status control's wake-up. disconnectedCallback() and setConfig() both drain it, zero the holds, and bump a generation counter, so that a release issued before the drain cannot decrement the new count.

The result is that a card torn down by a closing pop-up leaks no listener, cannot drive its hold count negative, and comes back live.

Stylesheets

A card's styles are css values, listed in its static styles: the shared ones from styles.ts (BASE_STYLE, HEADING_STYLE, ROW_STYLE, CONTROL_STYLE, MOTION_STYLE), a part's from its module, and the card's own from its file. ITEM_STYLE in parts/item.ts, for example, is shared by the entity, tile, header and media cards.

  • Adopted, not inlined. Lit makes one constructible stylesheet of each css value, and every element that lists it adopts that one.
  • Why: a full render has a few hundred cards. Adopting parses about 50 KB of CSS between them, where a <style> per card parses more than a megabyte.
  • Fallback: a browser without constructible stylesheets gets a <style> per element.
  • Shared values are custom properties. A colour several styles share is a custom property on BASE_STYLE's :host (--m-pill, --m-hover, --m-edge, --m-edge-strong), not a value spliced into each template, so every css template is a literal, which the build minifies. The one interpolation is a css value inside another: BASE_STYLE takes in MOTION_STYLE.

Reading state and calling services

A card reads hass.states, hass.entities, hass.devices and hass.locale, and formats through HA's own formatters. A press calls its service through the host's current hass (host.hass), not the one the button was drawn with: a toggle, a service control, the button card, a glance that toggles, a list row's reset, a message's clear, and a menu's choice. A lock or an alarm picks its service from the state at that moment. A slider sends its value through the hass its overlay opened with, or the slider card was last drawn with.

  • Readers (src/ha/hass.ts): stateOf, hasValue, isOn, numericState, numberAttribute, stringAttribute, listAttribute.
  • Services: callService(hass, entityId, 'domain.service', data), and toggleEntity(), which calls lock.lock / lock.unlock, climate.turn_on / climate.turn_off, or else homeassistant.toggle.
  • ServiceName is a closed union, so a typo is a compile error rather than a call that fails at runtime. A new service is added to it.
  • Errors reach the user. Every call is wrapped in run(host, promise), so a rejection becomes HA's own hass-notification toast rather than an unhandled rejection in the console.

Names, formatting and icons

Names (src/ha/names.ts):

  • entityName() is hass.entities[id].name. Failing that, it is the friendly name without the device name HA prefixes for has_entity_name entities. An empty name, HA's "Use device name", counts as none, so the entity prints its device's name.
  • nameOf() applies a card's naming options:
    • label: 'device' names a row, an item or a header after its device (name_by_user, then name);
    • strip removes a literal suffix;
    • strip_word removes a word, with its optional plural and leading space, wherever it falls; when it was the first word, the new first letter is capitalised, so "Tailscale router" reads "Router".
  • deviceSerial() reads the device's serial_number.

Formatting is HA's own:

  • hass.formatEntityState() gives precision, unit and translation.
  • hass.formatEntityAttributeValue() has a fallback that capitalises a raw word HA does not translate, such as a swing mode.
  • A timestamp is ha-relative-time.

The text the cards write themselves, all in English:

  • the words dictionaries, and the minutes of a seconds counter (minutes: true);
  • the car plan's captions (Hood, Tailgate, Sunroof, the corner names), its summary (All closed, One tyre low, 2 tyres dropping) and the — for a missing or unavailable value (DASH in src/ha/format.ts), which a table cell and every unavailable entity share;
  • the tyres chip's titles (Tyres, Tyre pressure dropping, Tyre pressure low);
  • the messages card's No notifications;
  • the clients card's No clients, and its Other and Clients headings;
  • the list card's summaries, Lowest ... and Highest ..., and a table's header before its sum;
  • the labels of the buttons that have no entity: Back, Close, Reset ..., Clear ..., ...: choose, Show the lights of ... / Hide ..., and the media card's Previous, Play / Pause, Next and Volume.

The state line (stateLine() in src/ha/format.ts) is one span per shown item, separated by •:

  • Each span is an inline flex box that centres the icon on the text, 3 px from it. Its text sits in a .value box of its own, so a part wider than its line, as in a tile at half a narrow section, ends in an ellipsis rather than a clipped glyph.
  • vertical-align: top keeps its baseline the line's.
  • The icon's line-height is 0, so that ha-icon's inline strut doesn't push it below the text.
  • Every item follows its when rule, a text included, so a line can read "None" while a count is 0 and the count otherwise.

Icons are HA's elements:

  • ha-state-icon for an entity.
  • ha-attribute-icon for a preset or swing mode.
  • ha-icon for a literal.
  • The HVAC modes are the exception: they use HVAC_ICONS (parts/menu.ts, HA's own icons for them). ha-attribute-icon has none, and an entity's custom icon would override the state icons.

Rules

Unknown keys are errors. A key a card does not know, at any depth it checks, makes it an error card that names the key and its path (unknown key: rows[1].lowes). The layout keys Home Assistant and card-mod add (grid_options, visibility, view_layout, layout_options, card_mod) are allowed at the top. The cards a pop-up holds are checked by those cards; the shell checks only their visibility.

Sizes. In a sections view, the button, select, slider and tile cards ask for half a section (6 of its 12 columns), the pop-up shell for 1, and every other card for the full section, each with its own height (getGridOptions()). grid_options overrides it.

A StateRule (is, not) matches the normalised state.

  • Normalising (normalise()) reads a number as a number, so 0.0 is 0; it lower-cases any other state and removes its hyphens, and reads a missing entity as unavailable. The rule's own entries are normalised the same way. So a rule may use the integration's spelling (SELECTIVE-LOCKED) or the normalised one (selectivelocked), and the same holds for words keys.
  • EntityRule reads another entity: the current room while the vacuum is cleaning, or the mop drying time while the mop drying switch is on.
  • when decides a colour, and show a visibility. A coloured pill, control or header without when lights on on (active() in src/ha/rules.ts); a list row without when is coloured whenever it is shown, since a flag carries its condition in show.
  • A status control's rules are tried in order. The first one that any of its entities matches colours the chip. The tooltip lists those entities with their states, named and worded as a list row's are: a rule's label: 'device' names them after their device, and its words renders their raw states.
  • A within_days rule holds while its timestamp is that many days off or less, judged against the clock at each draw. A page left open wakes its card for it (refresh(), through a teardown it registers): when the soonest of those timestamps comes within its days, or after an hour, whichever is first. So a key that comes due turns red on time, with no state change to draw it. Only a card on the page sets a wake-up, so a card a closed pop-up dropped keeps no timer; one that comes back draws again and sets it anew.

No value means no row; unavailable means orange. hasValue() and isUnavailable() in src/ha/hass.ts split a missing value in two:

  • No value (unknown, none, empty): the row or state item is not shown, so nothing hides a missing value by hand. A scene, a button or a sensor the device has not reported yet has nothing to say.
  • unavailable: the device is offline, which is a warning, so the element keeps its place and everything of it that takes a colour is UNAVAILABLE (src/ha/rules.ts), the orange of Design. Where its value would print, an em dash — stands (unavailableValue() in src/ha/format.ts): a role="img" glyph whose aria-label is HA's own word for the state, so a screen reader hears "Unavailable" rather than "em dash". The dash is the same glyph a table cell and the car plan print for a value that is missing; the colour is what tells the two apart.

Kind by kind, while the entity is unavailable:

Kind Shows
A state item Its icon if it has one, then the dash, both orange
A list row, a table cell The row stays with its icon, value (the dash) and bar orange; an empty bar. A table row stays while any of its values has a value or is unavailable
A pill, a nav or indicator chip Orange, whatever when says (tint() in src/ha/rules.ts)
toggle, slider, select, service Drawn as an inert display in the control's place, orange, titled "Name: Unavailable" (unavailable() in parts/controls.ts), so the outage is visible where the control lives. With no value they are not drawn at all (ACTIONS): a button that cannot work is not offered. A service control on a button entity is the exception (pressable()): a button's state is when it was last pressed, so unknown only means never, and it is drawn
A status chip Its own rules first; when none holds and one of its entities is unavailable, orange, with the tooltip listing those entities the way the rule names them
tyres Low and dropping first; otherwise orange while a tyre sensor is unavailable, titled "Tyres: Unavailable"
The slider card A disabled track with no fill, its icon and the dash orange, the name in the text colour (the unavailable property of mnml-slider, parts/slider.ts)
The select card The row without its menu: pill and dash orange
The car plan A tyre label reads the dash in orange; a part's tooltip reads "Unavailable" and the part is not drawn open
The media card The row stays, its pill and state line uncoloured: HA's state icon, and HA's word for the state. Its transport, volume and power controls are inert orange displays, as above, while the player still reports the features they need
The weather card Its fallback icon and HA's word for the state, uncoloured; the temperature and the facts are left out while the entity reports none
The button card Unchanged and uncoloured: a press still reaches HA, whose refusal comes back as a toast
nav, scenes, a serial item As always: navigation works whatever the entity reads, a scenes button is about a set of scenes, and a serial comes from the registry

Controls, sliders and menus

  • Controls (parts/controls.ts): renderControls() draws a lane. colorStyle() (src/ha/templates.ts) sets --m-color to var(--<color>-color), which the colored class reads; tint() picks the colour from the rule, or orange for an unavailable entity. A toggle also says aria-pressed from its entity's state, not from the rule, as Home Assistant's own toggles do: pressed while the entity has a value that is not off, nor locked for a lock, disarmed for an alarm or closed for a cover (isOn()). So a toggle coloured while off, as AdGuard's protection is, is not pressed then.
  • Sliders (parts/slider.ts, with each SliderKind's range, format and service call in sliderSpec(), parts/slider-spec.ts): a slider button opens its slider, an mnml-slider element, as an overlay over the card; the slider card draws the same element in place. The overlay closes on its X, the round close button after the slider, which gives the focus back to the slider button, 3 s after the last touch, or on a tap outside.
    • Opened from the keyboard (a click whose detail is 0), the slider takes the focus once it has drawn, and the overlay stays open while the focus is in it. Opened by a pointer, the focus stays where it was, so the 3 s close runs.
    • On a heading, the overlay covers the heading (.heading is positioned in OVERLAY_STYLE), with no inset, and its slider is as tall as the heading's controls, 36 px.
    • A drag sets the value when it ends.
    • The keyboard uses the arrow keys, Home and End, and commits 500 ms after the last key, or at once when the slider closes before then. A value sent as the slider closes goes through run() like any other, so its failure is a toast.
  • Menus (parts/menu.ts) are popovers built with the browser's popover API. sceneChoice() turns a scenes list into options named and iconed by the scene entities, each calling scene.turn_on. Its current option is the scene whose name is the state of the active_scene select.
    • A menu is a role="menu" of menuitemradio items with aria-checked, or of plain menuitems when there is no current value, as for scenes with none active. Its trigger carries aria-haspopup="menu" and aria-expanded.
    • Its trigger toggles it. A tap on the trigger, or anywhere on the select card's row, while its menu is open closes the menu: dismissable() counts the trigger as inside (trigger), and openMenu() closes the menu its anchor already has rather than opening a second one.
    • It opens with the current item focused and scrolled to the middle of the menu, so a long list such as 19 scenes shows it. The arrow keys move with wrap-around, Home and End jump, Tab and Escape close it, and a choice, Escape or Tab put focus back on the trigger.
  • Folds (section() with a Fold, parts/section.ts): the heading of a folding list or clients network is the tap target, and its chevron is a button with aria-expanded, labelled with the title. A tap toggles the card in place, and the card keeps the choice (choice, choices) until the page reloads; until then, open comes from the card's own rule. Unfolding scrolls the section into view (scrollIntoView with block: 'nearest', smooth unless reduced motion is set), in the pop-up's own scroll area or the page, whichever holds it; a section taller than the room shows its heading at the top. Folding never scrolls.
  • Escape closes one layer. A menu or an overlay consumes its Escape (preventDefault()), so the dialog's close watcher doesn't close the pop-up underneath in the same keystroke.

Navigation

Navigation is HA's own mechanism (src/ha/navigation.ts), and the URL is the only state a pop-up has.

  • navigate(hash) pushes the hash with a depth in its state, one more than the pop-up it was opened from, and dispatches location-changed.
  • closePopup() goes back depth entries, to the view's own entry, so a closed pop-up leaves nothing in history and the browser's back button leaves the dashboard. A pop-up that navigate() did not open, from a link or a reload, has no depth: it replaces its entry with the URL minus its hash, keeping HA's root marker.
  • back() goes back one entry from a pop-up opened from another one, and closes it otherwise.

Links. linkTo() (parts/item.ts) makes a row and its pill a way into a pop-up: an item with a popup, and the media card with one.

  • Both build the pop-up ahead on pointerdown (prebuild()) and open it on click, and both stop the event, so a link inside a tile doesn't also open the tile's pop-up.
  • The pill is the keyboard path (onPress(), Enter or Space), labelled with the row's name.
  • The row's own controls stop their taps, so the media card's transport, volume and power buttons work without opening anything.

The pop-up shell

Every pop-up of the dashboard lives in one mnml-popups-card (cards/popups.ts).

Why one shell.

  • It draws no state of its own, so it is not a MnmlCard: it watches no entity, and draws again only when the hash opens, changes or closes a pop-up, while its children get each hass from it directly. A MnmlCard would watch every entity its pop-ups name.
  • One shell costs four window listeners (the three route events and the prebuild request) and one route callback per hash change, however many pop-ups there are. That keeps a second view affordable: duplicating one card doesn't multiply anything.
  • It asks for one grid column, the smallest span, and its host has no height, so it takes one zero-height grid row. width is the dialog's width on a wide screen, and the width of every pop-up that unfolds.

A real <dialog>. It opens with showModal(), which gives the top layer (no ancestor can clip it), ::backdrop, Escape, the focus trap and modal semantics for free. Its aria-label is its header's name: the first card's name, or failing that its entity's name (after its device with label: 'device'). cancel and a click on the backdrop turn into closePopup() rather than being handled directly, so the hash stays the single source of truth. The shell never opens or closes itself: it follows the hash.

Routing waits for the page. HA configures a card before it inserts it, so setConfig() routes only while the element isConnected. Otherwise, a dashboard loaded at a URL that already carries a pop-up hash would call showModal() on a dialog in no document, which throws. The attempt would already have claimed the hash, so connectedCallback() would see nothing to do and the pop-up would never open. Configured while detached, the card waits, and connectedCallback() opens it.

How a pop-up opens. The shell picks open's choice for the screen: phone up to 600 px, tablet up to 1024 px, then desktop; left out, a sheet on a phone and a dialog elsewhere. The choice is a class on the dialog (sheet, dialog, unfold), so the styles follow the setting rather than a media query.

The scrollbar sits between the cards and the panel's edge. The body, which scrolls, ends 5 px short of the panel's right edge, through the panel's padding (mnml-popup-gap), and keeps 6 px clear on its content. A thin scrollbar then has about the same air on both sides, and an overlay scrollbar on a phone draws in that strip, never over a card. Home Assistant's own scrollbar-color styles the thumb, so the card sets only its width and place.

A shadow while there is more. The panel's bottom edge darkens (mnml-popup-scroll-shadow) while its body has more below it, and clears when the body is scrolled to the end. A scroll, and a resize of the body or of any card in it (a list unfolding, a card loading), re-measure it.

Unfold. A pop-up set to unfold opens out of what it was opened from, its origin: a tile, a row, a chip or a glance (parts/popup-unfold.ts holds the panel's place and clips, the scroll that makes room, the wait for an origin to settle and the following of the origin):

  • Where from. A tap records what it came from: navigate(hash, from) keeps the origin. A tapped glance or nav chip is its own origin, the badge itself and not the strip or the tile around it; a tapped row names the card whose shadow root holds it (openedFrom(event)); and every tile offers itself for its own popup each time it draws (offerOrigin). An origin that has left the page is no origin, and a pop-up without one opens as a dialog.
  • Its place. The panel is the shell's width wide, never wider, whatever the width of its origin, and no wider than the window less 8 px at each side. It starts from the origin's edge nearer the window's: an origin in the left half grows right from its left edge, one in the right half grows left from its right edge; either way it stays 8 px inside the window. It grows down from the origin's top, or, with less than 420 px below the origin and more room above, up from the origin's bottom. It scrolls inside when it is taller than the room it has.
  • Scrolled into view. Before it opens, the page scrolls (smoothly, or at once with reduced motion) so that the origin has 420 px below it, or, when the origin is cut off at the top, so that its top is in view. The scroll goes no further than the page allows, and an origin that already has the room does not move the page. Where the page cannot scroll enough, the panel opens upward or scrolls inside, as above.
  • Its look. The panel is revealed from the origin's rectangle outward with a clip, in 300 ms, so its text never squashes, and folds back into the origin when it closes. The backdrop is lighter than a dialog's, and the panel is a card: the cards' fill, edge, highlight and the pop-up's shadow. With reduced motion it opens in place without the animation.
  • It stays with its origin. While it is open, the panel re-measures its origin on every frame and moves when the origin does, so a resize or a late layout carries it along. A pop-up opened from inside it takes over its place and its origin, so Lights inside a room swaps the content without moving.
  • On a page load. A dashboard loaded at a pop-up's hash has no tap: for that hash, the shell waits up to 600 ms for its tile to offer itself, and until the tile has kept still for 6 frames (at most 1.5 s), since the cards above it are still drawing and moving it. A nested pop-up's row is not on the dashboard after a reload, so it opens as a dialog. A pop-up whose hash clears while it waits does not open: each open and each close takes a turn (turn), and an open goes on only while its turn is the last.

Pop-ups from other cards. A template card gives the shell its pop-ups through parts/popup-registry.ts: announce(owner, popups) while it is connected, withdraw(owner) when it leaves. The shell looks a hash up among its own popups first, then the announced ones in the order they came, the first owner of a hash keeping it. A hash announced twice, or announced and also in the shell's own popups, is a console error, reported once until the announcements change. An announcement re-routes, so a pop-up whose hash is already in the URL opens as soon as its template card announces it.

Children are built lazily. A pop-up's cards are arbitrary Lovelace cards, built through HA's loadCardHelpers().createCardElement(). So its children are typed only as Lovelace cards (LovelaceCardConfig), and the one key of theirs it reads, visibility, with the shared Condition.

  • They are built on open and dropped on close. A tile asks for a build ahead on pointerdown (prebuild()), so the dialog opens already filled. A link inside the tile (a pill, a row, a nav chip) stops that pointerdown, so only its own pop-up is built ahead.
  • A build in flight is shared: a second request for the same hash, or the open itself, waits for it rather than building again.
  • They receive hass on every state change.
  • visibility is the shell's. createCardElement() returns the bare card, not the hui-card wrapper that evaluates visibility in a view. So the shell evaluates a child's state conditions itself (state or state_not, one state or a list), on build and on every hass, and sets hidden on the card; a hidden child takes no space. A missing entity reads as unavailable, as it does for HA. A condition of another kind is an error at setConfig() that names its path, rather than a card that never hides.

Scrolling stays inside.

  • While a pop-up is open, the page behind it doesn't scroll.
  • A drag inside content that can scroll, or inside a nested scroller of its own, is left alone.
  • A drag on the header, on the backdrop, or inside content that cannot scroll is cancelled rather than passed to the page.
  • A swipe down closes it, however it opened: a sheet, a dialog or an unfolded panel (parts/popup-swipe.ts, whose handlers the shell puts on every panel). A touch or pen drag that starts in the top 88 px of the panel, with the content not scrolled, pulls the whole panel down with the finger once it has moved 10 px; a mouse never does, and a drag that goes up first, or starts lower, is left alone. Past 80 px, or as a quick flick of more than 20 px, the panel slides off the screen in 200 ms and the address is cleared at once, as the close button does (closePopup(), so a pop-up opened from another closes with it); a shorter, slower drag springs back in 180 ms. With reduced motion the pop-up goes at once.

Motion

Motion is short, and only where something appears or leaves (src/ha/motion.ts, MOTION_STYLE):

What Enters Leaves
Backdrop fades in (mnml-fade, 140 ms) fades out with the dialog (dialog.leaving::backdrop)
Dialog rises (mnml-rise, 160 ms) a 6 px fade over FADE_LEAVE
Sheet slides up from the bottom (mnml-sheet, 220 ms) slides back to translateY(100%) over SHEET_LEAVE
Unfolded pop-up unclips from its origin (UNFOLD, 300 ms) folds back into its origin over FOLD, or fades as a dialog when the origin has left the page
Scenes menu rises (140 ms) none
  • Leaving is animated. A CSS animation cannot do this for an element about to be removed. So shut() runs the leaving keyframes through the Web Animations API (leave()) and closes the dialog when they finish.
  • The dialog draws no focus ring. showModal() focuses the dialog itself, which fills the viewport, so its ring would frame the whole page; a Tab moves on to its first control, which draws its own.
  • One pop-up to another keeps the dialog, and the panel. Routing from an open pop-up to another changes only what is in the panel: the old header and body slide 20 px aside and fade (110 ms), the new ones slide in from the other side (200 ms), and the panel's height morphs from the old to the new. Opening goes in from the right and going back, by the back button or the browser's, from the left, by the history's depth. So the backdrop stays as it is rather than fading out under a new one fading in, and an unfolded pop-up keeps its place. With reduced motion the content is replaced at once.
  • One choice of opening. SHEET (600 px) and the tablet's 1024 px are read only by narrow(), to pick open's choice; the layout and the animation follow the class that choice puts on the dialog, so they cannot disagree.
  • Reduced motion. reduced() skips all of it and closes at once. MOTION_STYLE turns every animation and transition off under prefers-reduced-motion: reduce.
  • Only there. Only what the cards build and remove animates: pop-ups, menus, slider overlays and press feedback. A value never animates: no transition sits on a value, a fill or a state's colour, so a slider's fill follows the finger exactly during a drag, and arrives instantly from the state.

The YAML writer

src/contract/yaml.ts, the writer of build()'s dashboard and the demo's package, writes YAML 1.1, the version HA's loader reads, without merge keys. It single-quotes, wherever they appear, both as values and as mapping keys, every string that loader would read as something else:

  • the YAML 1.1 booleans (on, off, yes, no, y, n);
  • numbers in YAML 1.1's forms: sexagesimals (3:1), with underscores (1_000), binary (0b101);
  • dates (2026-10-13);
  • = and <<, which that loader refuses unquoted;
  • and what a YAML 1.2 reader, the MNML panel's, reads as a number (0o17, 1e3).

That keeps them strings: a words key of on would otherwise load as true and never match. The writer also quotes whatever else needs it, a hex colour for one. A configuration written by hand, or by another generator, needs the same care: quote 'on' and 'off' where they are strings. A string that needs no quotes stays plain.

What Home Assistant's frontend provides

What the cards take from HA's frontend, which only the live instance can confirm; the first suspects when something reported from it looks wrong.

  • Formatting. hass.formatEntityState() and hass.formatEntityAttributeValue() give precision, units, translations and the brightness in percent. So a sensor with no display precision shows every decimal it reports; set one in the entity's settings. The car plan formats the tyre pressures itself, and reads the same setting (display_precision in hass.entities). A utility meter with no value shows "—".
  • The registries. hass.entities and hass.devices provide the names, and the serial_number a serial entry prints. An entry whose device has none prints nothing.
  • Locale. hass.locale becomes a new object when the profile's language changes.
  • Icons and time. ha-state-icon follows the state. ha-attribute-icon knows the preset and swing modes it knows; a swing mode it has no icon for shows none. ha-relative-time renders timestamps.
  • Pop-up plumbing.
    • loadCardHelpers().createCardElement() returns the bare card, not a hui-card. So the shell passes hass to a card inside a pop-up and evaluates its visibility itself, and so does the template card for the card it draws.
    • location-changed reaches the pop-up shell.
    • history.state holds HA's root marker.
    • show_header: false leaves a drag strip and no header.
    • The map card's height comes from aspect_ratio inside a pop-up.
  • Template families. The template card fetches the shipped families, mnml-cards-<family>.json, from beside the bundle: their URLs resolve against the bundle's own (import.meta.url), so they come from wherever the bundle is served (the integration's /mnml-files/), and a family's ?v= comes from the bundle.
  • Templates. The template card subscribes to the integration's store with mnml/templates/subscribe (Where templates live). The MNML panel draws its preview with loadCardHelpers(), which Home Assistant defines only once its Lovelace module has loaded; on the panel's page that module is loaded through partial-panel-resolver's _getRoutes(), which is not a promised API. A frontend without it leaves the preview saying it cannot draw, and the rest of the panel works. Discovery reads hass.areas, hass.devices and hass.entities, the last with platform, translation_key and entity_category.
  • Browser APIs. The popover API, for the menus. And lostpointercapture firing at a capture target that was removed from the document: that is what settles a drag when the slider overlay closes under a held pointer, and it is the one thing between that and a hold that never releases.
  • Dark mode. A theme's modes, the MNML theme's included, follow the operating system through each profile's dark mode setting (Auto), not through the theme.

Clone this wiki locally