I built a small custom card that generates any other card from a Jinja2 template, and re-renders it live whenever the state behind it changes. I’ve been running it in production on my own dashboard for a while now, so I wanted to share what it does in case someone else finds it useful. Earlier I used auto-entities card, but it was both overkill for my use and it’s really slow.
What it does
custom:template-render-card takes a Jinja2 template that outputs JSON and renders the result as a real card. The JSON becomes the config for any standard or custom card (tile, markdown, picture-elements, mushroom, custom:layout-card…).
type: custom:template-render-card
template: !include card_template.yaml
The template can live inline or in a separate file (!include), so you can keep your logic tidy and reuse it across popups and views.
The key thing: it’s live
Most “dynamic card” hacks only render once on page load. This one uses Home Assistant’s render_template subscription stream, so the card re-renders automatically the moment any state or attribute referenced in the template changes — battery drops below 20%, someone walks into a zone, the last pickup is 3 days away… the card updates itself. No manual refresh, no full dashboard reload.
Extra context the card injects into your template
On top of the usual template state, it exposes a few very handy variables:
| Variable | What it gives you |
|---|---|
user / user_obj |
The name and full object of the currently logged-in user |
language |
Current frontend language |
theme |
Currently active theme |
config |
The card’s own config |
browser_mod_installed / browser_id |
Whether browser_mod is present, and this browser’s ID |
browser_entities |
A ready-made map of this browser’s browser_mod entity IDs (battery, screen, visibility, user agent, dark mode, panel, path, charging…) — prefix detection included |
session |
Everything in the browser’s sessionStorage |
That makes user-aware, device-aware cards possible: show the current viewer their own phone’s battery and activity, and show everyone else their own.
What I use it for
A few real examples from my own dashboard:
1. Per-person presence view. One template loops over every person entity and generates a picture-elements card per person — profile photo with a green/red border for home/away, “home / away / for 12 minutes” text, battery level with a color-coded icon, activity icon (walking/driving/running), connection type, and a tap that opens a browser_mod popup with the details of that person’s active device. The card sees its own browser via browser_entities, so it can identify “this person is looking at me on their phone right now.”
2. Waste-collection popup. A template filters the four waste sensors to only those with ≤ 3 days to pickup, computes how many are relevant, then positions them horizontally in a picture-elements card with “Om X dager” under each. When the count changes, the whole layout redistributes automatically.
3. A whole family of popups. Humidity, lighting, air pressure, climate events, temperature, battery, media, person details, security events, doors/windows, fire alarm… all built on this same card, reusing shared Jinja macros ({% from 'hjemme-dashboard.jinja' import … %}) so popups and main views stay consistent.
4. The rooms overview — generated rather than hand-maintained.
Editor & resilience
- Ships a visual editor: a
ha-code-editorin Jinja2 mode with entity autocomplete, debounced saves, and a link to a bundledREADME.md. - User switching is handled: the subscription is torn down and re-established when the active user changes.
- Graceful fallback: if the live subscription fails it falls back to a one-shot render instead of showing a blank box.
- Good errors: template/JSON problems render an
ha-alertwith the generated JSON, so debugging a broken template is straightforward. - No wasted work: skips re-rendering when the generated config is unchanged, and fires an
iron-resizeafter render so grids lay out correctly.
Why I like it
It collapses the distance between “what I want to show” and “what’s actually on screen.” If you’re comfortable writing Jinja2, you get a fully dynamic dashboard without maintaining a pile of near-identical card YAML — loop over entities, compute positions, build layouts, all in one place, and it stays live.
The component
Drop this in www/community (or your card folder), load it as a resource in your dashboard, and register the card as custom:template-render-card.
class TemplateRenderCardEditor extends HTMLElement {
setConfig(config) {
this._config = config;
this._render();
}
set hass(hass) {
this._hass = hass;
const codeEditor = this.shadowRoot?.querySelector(“ha-code-editor”);
if (codeEditor) {
codeEditor.hass = hass;
}
}
_render() {
if (this._rendered) {
const codeEditor = this.shadowRoot.querySelector(“ha-code-editor”);
if (codeEditor && codeEditor.value !== this._config.template) {
codeEditor.value = this._config.template || “”;
}
return;
}
const currentFolder = new URL(".", import.meta.url).pathname;
const readmeUrl = `${currentFolder}README.md`;
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
.container {
display: flex;
flex-direction: column;
gap: 8px;
font-family: var(--paper-font-body1_-_font-family, sans-serif);
}
label {
font-weight: bold;
color: var(--secondary-text-color);
}
ha-code-editor {
display: block;
width: 100%;
border: 1px solid var(--divider-color, #444);
border-radius: 4px;
overflow: hidden;
}
.readme-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--primary-color, #03a9f4);
text-decoration: none;
font-size: 0.9em;
margin-top: 4px;
font-weight: 500;
width: fit-content;
}
.readme-link:hover {
text-decoration: underline;
}
</style>
<div class="container">
<label>Jinja2 Template (Genererer JSON):</label>
<ha-code-editor
mode="jinja2"
autocomplete-entities
autocomplete-strings
dir="ltr"
></ha-code-editor>
<a href="${readmeUrl}" target="_blank" class="readme-link">
📖 Åpne dokumentasjon (README.md)
</a>
</div>
`;
const codeEditor = this.shadowRoot.querySelector("ha-code-editor");
codeEditor.value = this._config.template || "";
codeEditor.hass = this._hass;
codeEditor.addEventListener("value-changed", (ev) => {
const rawValue = String(ev.detail.value);
this._config = { ...this._config, template: rawValue };
if (this._debounceTimer) {
clearTimeout(this._debounceTimer);
}
this._debounceTimer = setTimeout(() => {
this._debounceTimer = null;
const event = new CustomEvent("config-changed", {
detail: { config: this._config },
bubbles: true,
composed: true,
});
this.dispatchEvent(event);
}, 300);
});
this._rendered = true;
}
}
customElements.define(“template-render-card-editor”, TemplateRenderCardEditor);
class TemplateRenderCard extends HTMLElement {
constructor() {
super();
this._subscribed = false;
this._unsub = null;
this._storageListener = null;
}
static getConfigElement() {
return document.createElement(“template-render-card-editor”);
}
static getStubConfig() {
return {
template:
‘{\n “type”: “tile”,\n “entity”: “update.home_assistant_core_update”,\n “name”: “Min Template Tile”\n}’,
};
}
connectedCallback() {
if (this._storageListener) {
window.removeEventListener(
“session_storage_oppdatert”,
this._storageListener
);
}
this._storageListener = () => {
if (this._subscribed) {
this._unsubscribe();
this._subscribe();
}
};
window.addEventListener("session_storage_oppdatert", this._storageListener);
if (!this._subscribed && this._config?.template) {
this._subscribe();
}
}
set hass(hass) {
const oldHass = this._hass;
this._hass = hass;
if (this._cardElement) {
this._cardElement.hass = hass;
}
const userChanged = oldHass?.user?.id !== hass?.user?.id;
if (
(!this._subscribed && this._config?.template && this.isConnected) ||
(userChanged && this.isConnected)
) {
if (userChanged) this._unsubscribe();
this._subscribe();
}
}
setConfig(config) {
if (!config.template) {
throw new Error(“Du må definere en template-streng.”);
}
if (this._config && this._config.template !== config.template) {
this._config = config;
this._unsubscribe();
if (this.isConnected) {
this._subscribe();
}
} else {
this._config = config;
}
}
async _subscribe() {
if (this._unsub) {
await this._unsubscribe();
}
this._subscribed = true;
const currentUser =
this._hass && this._hass.user
? this._hass.user
: { name: "Ukjent Bruker", id: "", is_admin: false, is_owner: false };
const currentLanguage =
this._hass && this._hass.language ? this._hass.language : "no";
const currentTheme =
this._hass && this._hass.themes && this._hass.themes.theme
? this._hass.themes.theme
: "default";
const hasBrowserMod = window.browser_mod !== undefined;
const currentBrowserId =
hasBrowserMod && window.browser_mod.browserID
? window.browser_mod.browserID
: "unknown_browser";
const sessionData = {};
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
sessionData[key] = sessionStorage.getItem(key);
}
let browserEntities = undefined;
if (hasBrowserMod && currentBrowserId !== "unknown_browser") {
const bid = currentBrowserId.toLowerCase().replace(/[^a-z0-9_]/g, "_");
// Discover any entity prefix (e.g. "browser_mod_") so browser_entities
// points at the right names even when the integration uses a prefix.
let prefix = "";
if (this._hass && this._hass.states) {
const probe = Object.keys(this._hass.states).find((eid) =>
new RegExp(`_${bid}_browser_visibility$`).test(eid)
);
if (probe) {
const domainEnd = probe.indexOf(".");
prefix = probe.slice(domainEnd + 1, probe.indexOf(bid));
}
}
const q = `${prefix}${bid}`;
browserEntities = {
browserID: `sensor.${q}`,
path: `sensor.${q}_browser_path`,
visibility: `sensor.${q}_browser_visibility`,
userAgent: `sensor.${q}_browser_useragent`,
currentUser: `sensor.${q}_browser_user`,
fullyKiosk: `binary_sensor.${q}_browser_fullykiosk`,
width: `sensor.${q}_browser_width`,
height: `sensor.${q}_browser_height`,
darkMode: `binary_sensor.${q}_browser_dark_mode`,
activity: `binary_sensor.${q}_browser`,
screen: `light.${q}_browser_screen`,
player: `media_player.${q}`,
panel: `sensor.${q}_panel`,
battery_level: `sensor.${q}_browser_battery`,
charging: `binary_sensor.${q}_browser_charging`,
camera: `camera.${q}`,
};
}
try {
const subscribePromise = this._hass.connection.subscribeMessage(
(msg) => {
if (!this._subscribed) return;
this._renderCard(msg.result);
},
{
type: "render_template",
template: this._config.template,
variables: {
user: currentUser.name,
user_obj: currentUser,
language: currentLanguage,
theme: currentTheme,
config: this._config,
browser_mod_installed: hasBrowserMod,
browser_id: currentBrowserId,
browser_entities: browserEntities,
session: sessionData,
},
}
);
const unsub = await subscribePromise;
if (!this._subscribed) {
unsub();
return;
}
this._unsub = unsub;
} catch (err) {
if (this._subscribed) {
console.error(
`Template-render-card: abonnement feilet (${this._config.template}): ${err.message}`
);
try {
// Fallback: try a one-shot render instead of showing an error
const result = await this._hass.connection.sendMessagePromise({
type: "render_template",
template: this._config.template,
variables: { config: this._config },
});
if (result) this._renderCard(result);
} catch (e) {
this._showError("Abonnement på template feilet: " + e.message);
}
}
}
}
async _unsubscribe() {
this._subscribed = false;
if (this._unsub) {
if (typeof this._unsub === "function") {
try {
this._unsub();
} catch (e) {
console.error("Feil under avmelding av template:", e);
}
}
this._unsub = null;
}
}
async _renderCard(templateResult) {
try {
if (!templateResult) return;
let cardConfig;
if (typeof templateResult === "object") {
cardConfig = templateResult;
} else {
const cleanJson = templateResult.trim();
if (!cleanJson) return;
cardConfig = JSON.parse(cleanJson);
}
if (JSON.stringify(this._currentConfig) === JSON.stringify(cardConfig))
return;
this._currentConfig = cardConfig;
if (!window.loadCardHelpers) {
this._showError("Home Assistant frontend-hjelpere er ikke klare ennå.");
return;
}
const helpers = await window.loadCardHelpers();
const element = helpers.createCardElement(cardConfig);
element.hass = this._hass;
while (this.firstChild) {
this.removeChild(this.firstChild);
}
this.appendChild(element);
this._cardElement = element;
setTimeout(() => {
this.dispatchEvent(
new CustomEvent("iron-resize", { bubbles: false, composed: false })
);
}, 0);
} catch (err) {
const errorContext =
typeof templateResult === "object"
? JSON.stringify(templateResult, null, 2)
: templateResult;
this._showError(
`JSON-feil: ${err.message}\n\nGenerert kode:\n${errorContext}`
);
}
}
_showError(message) {
while (this.firstChild) {
this.removeChild(this.firstChild);
}
const alert = document.createElement(“ha-alert”);
alert.setAttribute(“alert-type”, “error”);
const pre = document.createElement(“pre”);
pre.style.whiteSpace = “pre-wrap”;
pre.textContent = message;
alert.appendChild(pre);
this.appendChild(alert);
this._cardElement = null;
this._currentConfig = null;
}
disconnectedCallback() {
this._unsubscribe();
if (this._storageListener) {
window.removeEventListener(
“session_storage_oppdatert”,
this._storageListener
);
this._storageListener = null;
}
}
getCardSize() {
return this._cardElement &&
typeof this._cardElement.getCardSize === “function”
? this._cardElement.getCardSize()
: 2;
}
}
templateRenderCardDefinition();
function templateRenderCardDefinition() {
if (!customElements.get(“template-render-card”)) {
customElements.define(“template-render-card”, TemplateRenderCard);
}
}
window.customCards = window.customCards || ;
const isRegistered = window.customCards.some(
(c) => c.type === “template-render-card”
);
if (!isRegistered) {
window.customCards.push({
type: “template-render-card”,
name: “Template Render Card”,
preview: true,
description: “Rendrer dynamiske kort generert via Jinja2/JSON-templates.”,
});
}
Example usage 1 — the minimal one
A simple self-updating tile, the default stub:
type: custom:template-render-card
template: |
{
“type”: “tile”,
“entity”: “update.home_assistant_core_update”,
“name”: “Min Template Tile”
}