Template Render Card - turn any Jinja2 template into a live, dynamic Home Assistant card

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-editor in Jinja2 mode with entity autocomplete, debounced saves, and a link to a bundled README.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-alert with 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-resize after 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”
}

2 Likes

Could you repost the code in such a way that we can copy/paste it? Or even better: push it to your github repository? :slight_smile: