Loading...
E.G.

Custom Cards in Home Assistant: Mushroom and button-card

September 30, 2026
Table of Contents

The stock cards in Home Assistant get you a working dashboard in an afternoon. I wrote that one up in five build steps and left it there for a while. What they do not give you is control over typography, over how a number is drawn, or over a ring that shows a battery level. For that you need custom cards, and this post is about the four I ended up with, the theme that holds them together, and the four things that broke while building it.

Everything here runs on a demo instance on a small Hetzner VM with simulated sensors, the same one the theme comparison was shot on. The full YAML of the finished dashboard is in the templates post. This post explains the parts of it that are not obvious from reading the file.

Four cards, loaded without HACS

The four custom cards are card-mod (CSS injection into any card), Mushroom (the compact tile cards), mini-graph-card (the line charts) and button-card (a card that renders whatever JavaScript you hand it). On a normal installation you install them through HACS and HACS registers the resources for you. On the demo VM I wanted the setup reproducible from the repository alone, so the four bundles sit under www/cards and get loaded from configuration.yaml:

frontend:
  themes: !include_dir_merge_named themes
  extra_module_url:
    - /local/cards/card-mod.js
    - /local/cards/mushroom.js
    - /local/cards/mini-graph-card-bundle.js
    - /local/cards/button-card.js
    - /local/ha-fonts.js

The fifth entry is not a card. It is the font loader, and it exists because of the first thing that turned out to be impossible.

Themes cannot declare fonts

The design direction for this dashboard was an instrument panel: labels small, uppercase and widely spaced in Archivo, values large and calm in IBM Plex Mono with tabular figures. Tabular figures matter more than they sound. With proportional digits a value like 2.449 W changes width every time a digit changes, and the whole tile twitches once a minute. In a screenshot that is invisible. In operation it is the difference between a display and a fidget.

A Home Assistant theme can only set CSS variables. It cannot declare an @font-face rule, so the fonts have to come from somewhere else. A tiny ES module loaded through extra_module_url appends the rules to the document head, and because @font-face is document-wide it also applies inside the shadow roots of the cards:

// www/ha-fonts.js
const CSS = `
@font-face {
  font-family: 'Archivo';
  src: url('/local/fonts/archivo-var.woff2') format('woff2-variations');
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/local/fonts/plexmono-500.woff2') format('woff2');
  font-weight: 500;
  font-display: swap;
}`;
const style = document.createElement("style");
style.textContent = CSS;
document.head.appendChild(style);

The font files are served from /local/fonts on the same machine, 76 KB for both families. No request to fonts.googleapis.com. For a blog about running software yourself, loading the typeface from Google would be an odd exception, and it also means the dashboard renders identically without internet. One small surprise on the way: Google's font API returned the same variable font file for all four Archivo weights I requested. Four downloads, one file, 35 KB. Only the checksums showed it.

Mushroom template cards as value tiles

The three tiles under the energy chart, consumption, feed-in and grid import, are Mushroom template cards. The template card takes a primary and a secondary text, both Jinja, an icon and an icon colour that can also be a template. That last part is what makes the tiles readable at a glance: grid import turns red only while there is any, feed-in turns teal only while there is any, otherwise both are grey.

- type: custom:mushroom-template-card
  primary: Consumption
  secondary: "{{ states('sensor.demo_house_load') | int }} W"
  icon: mdi:home-lightning-bolt
  icon_color: grey
  layout: vertical
  card_mod: &valueblock
    style: |
      ha-card {
        border: 1px solid var(--ha-card-border-color);
        padding-bottom: 4px;
      }
      mushroom-state-info$: |
        .primary {
          font-family: Archivo, sans-serif !important;
          font-size: 0.7rem !important;
          letter-spacing: 0.14em;
          text-transform: uppercase;
        }
        .secondary {
          font-family: 'IBM Plex Mono', monospace !important;
          font-size: 1.15rem !important;
          font-variant-numeric: tabular-nums;
        }

- type: custom:mushroom-template-card
  primary: Grid import
  secondary: "{{ states('sensor.demo_grid_import') | int }} W"
  icon: mdi:transmission-tower-export
  icon_color: >
    {{ 'red' if states('sensor.demo_grid_import') | int > 0 else 'grey' }}
  layout: vertical
  card_mod: *valueblock

The card-mod block is what enforces the typography. The mushroom-state-info$ selector with the dollar sign reaches into the shadow root of the card, which is where the primary and secondary spans live. The YAML anchor at the top of the first card and the alias on the others keep the style in one place. Eleven template cards on this dashboard share two such anchors.

The battery ring is one stroke, not a disc with a hole

The storage tile shows the state of charge as a ring. My first version was a button-card with a conic-gradient background and a smaller circle on top of it, whose background was set to var(--ha-card-background) to punch the hole. That produced a full disc. Inside the shadow DOM of button-card the variable does not resolve, so the inner circle was transparent and the gradient showed through.

The version that works draws the ring as an SVG circle and uses stroke-dasharray to show the filled portion. A ring drawn as one stroke has no hole that could go wrong. The colour switches at 60 and 25 percent, and the whole thing is a custom field in button-card, computed in JavaScript from the sensor state:

- type: custom:button-card
  entity: sensor.demo_battery_soc
  show_name: false
  show_state: false
  show_icon: false
  custom_fields:
    ring: |
      [[[
        const soc = Math.max(0, Math.min(100,
          Number(states['sensor.demo_battery_soc'].state) || 0));
        const col = soc >= 60 ? '#54cabe' : (soc >= 25 ? '#f0a44a' : '#e0685f');
        const R = 52, C = 2 * Math.PI * R;
        const dash = (soc / 100) * C;
        return `
          <svg class="ring" viewBox="0 0 120 120">
            <circle class="track" cx="60" cy="60" r="${R}"/>
            <circle class="val" cx="60" cy="60" r="${R}"
                    stroke="${col}"
                    stroke-dasharray="${dash.toFixed(1)} ${(C - dash).toFixed(1)}"
                    transform="rotate(-90 60 60)"/>
            <text class="num" x="60" y="58">${soc}</text>
            <text class="lbl" x="60" y="76">STATE OF CHARGE</text>
          </svg>`;
      ]]]

Two more things that broke

Markdown cards as section headings destroyed the grid. I had used markdown cards for the small uppercase section labels. A markdown card counts as a grid cell, so the tiles below it wrapped onto the wrong columns. The native heading card, available since the sections layout, spans the full section width and does not take part in the grid. Every section label on the dashboard is a heading card with card-mod for the letter spacing.

The slider stayed neon yellow. The target temperature control at the bottom right is a Mushroom number card. I had set Mushroom's own colour variable in the theme, and the slider ignored it. Mushroom colours that slider through Home Assistant's own --rgb-state-number variable, and I found that by reading the computed styles in the running frontend rather than by guessing. Both variables now sit in the theme:

# themes/instrument.yaml (excerpt)
instrument:
  modes:
    dark:
      mush-rgb-state-number: "240, 164, 74"
      # HA colours the input_number slider via --rgb-state-number,
      # Mushroom's own variable is not enough.
      rgb-state-number: "240, 164, 74"
    light:
      mush-rgb-state-number: "180, 106, 18"
      rgb-state-number: "180, 106, 18"

A fourth one is a testing problem rather than a design problem. Light mode kept showing Home Assistant's default white instead of my theme. The frontend.set_theme service only sets the server default per mode, and as long as a user profile says backend-selected it is not reliably controllable which mode applies. For reproducible screenshots the theme has to be set explicitly, which the screenshot script does by writing selectedTheme into localStorage before it loads the page.

What I would not do again

The instrument dashboard uses four custom cards and about 300 lines of YAML for what the stock version does in 23 cards and no plugins. The difference is real, and the two screenshots in the templates post show it, but the maintenance cost is real too. Each of the four cards is a separate project with its own release cycle, and card-mod in particular reaches into shadow DOM selectors that the Mushroom maintainers can rename at any time. The value tiles will survive a Mushroom update. The card-mod typography might not.

If I were starting a dashboard for daily use rather than for a blog series, I would take Mushroom alone and skip card-mod, accept the default typography, and spend the time on the sensors instead. The ring I would keep. It is one card, it depends on nothing but button-card, and it shows a measurement.

Frequently asked questions

Do I need HACS to use Mushroom or button-card?

No. HACS downloads the bundle and registers the resource for you, which is convenient, but the cards are plain JavaScript modules. Put the file under www and list it under extra_module_url, or add it as a dashboard resource in the UI. The demo VM loads all four this way so the setup lives entirely in the repository.

Why does card-mod need the dollar sign in the selector?

The dollar sign tells card-mod to enter the shadow root of the element named before it. Mushroom renders its texts inside mushroom-state-info, whose internals are not reachable from the outside with a normal CSS selector. Without the dollar sign the rule applies to the host element and does nothing visible.

Does the SVG ring update live?

Yes. button-card re-evaluates the JavaScript in a custom field whenever the entity it is bound to changes state. On the demo VM the state of charge sensor updates every few seconds, and the ring follows without a page reload.

Related articles