Lovelace: Button card

I have created this status block. Intent is to go to home view, if you click on home icon and to settings view if you click on gear.

Enclosed code works great in my browser. If I click exactly on gear, the settings view opens. Clicking somewhere else is going back to home.

Unfortunately, this is not working on the (iPhone) app. My approach is a tap_action on the total button-card and a specific custom field for the specific gear button. My guess is that since the home and gear areas are overlapping it’s luck that this works in browser.

I would like to create 3 areas (left, middle and right). Left and right slightly bigger than icon and rest is middle. In this case I will link left and middle both to the home view. Three columns will give me flexibility for the future.

Tried a lot but was not successful. Can someone provide a tip on how to define the 3 areas

Thanks.

type: custom:mod-card
card_mod:
  style: |
    ha-card { width: 380px; margin: 0; }
card:
  type: vertical-stack
  cards:
    - type: custom:button-card
      name: Warm water
      show_state: false
      show_icon: false
      tap_action:
        action: navigate
        navigation_path: /huis/0
      styles:
        grid:
          - grid-template-areas: "'home n gear' 'info info info'"
          - grid-template-columns: 40px 1fr 40px
          - grid-template-rows: auto 1fr
        card:
          - padding: 4px
          - height: 180px
          - width: 380px
          - overflow: hidden
        name:
          - font-size: 14px
          - font-weight: bold
          - text-align: left
          - padding-bottom: 4px
        custom_fields:
          home:
            - display: flex
            - align-items: center
            - justify-content: center
          gear:
            - display: flex
            - align-items: center
            - justify-content: center
          info:
            - padding: 4px
      custom_fields:
        home: >
          <ha-icon icon="mdi:home" style="color: var(--primary-color); width:
          38px; height: 38px;"></ha-icon>
        gear: |
          [[[
            // Alarm-badge op het tandwiel + eigen navigatie (geen bubbels)
            const anyAlarm =
              states['binary_sensor.glbal_qube2']?.state === 'on' ||
              states['binary_sensor.al_maxtime_antileg_active_qube2']?.state === 'on' ||
              states['binary_sensor.al_maxtime_dhw_active_qube2']?.state === 'on';
            const alarmDot = anyAlarm
              ? '<span style="position:absolute; top:-2px; right:-2px; width:10px; height:10px; background:var(--error-color); border-radius:50%; box-shadow:0 0 0 2px white;"></span>'
              : '';
            return `
              <div style="position:relative; width:38px; height:38px; display:flex; align-items:center; justify-content:center; cursor:pointer;"
                   onclick="event.stopPropagation(); window.history.pushState(null,'','/huis/wp2-instelling'); window.dispatchEvent(new Event('location-changed'));">
                <ha-icon icon="mdi:cog" style="width:22px; height:22px; color: var(--primary-text-color);"></ha-icon>
                ${alarmDot}
              </div>
            `;
          ]]]
        info: |
          [[[
            // ===== Helpers =====
            const safeNum = (eid,d=1)=>{const s=states[eid]?.state;if(!s||s==='unknown'||s==='unavailable')return null;const v=parseFloat(s);return isNaN(v)?null:parseFloat(v.toFixed(d));};
            const safeTxt = (eid)=>{const s=states[eid]?.state;return (s&&s!=='unknown'&&s!=='unavailable')?s:'';};

            // ===== Entities =====
            const statusCode = parseInt(states['sensor.unitstatus_qube2']?.state ?? 'NaN');  // 0,1,6,8,9,15,16,17,22
            const serviceTxt = safeTxt('sensor.qube_driewegklep_dhw_cv_status_2');           // 'CV' of 'SWW'
            const tDHWT      = safeNum('sensor.dhw_temp_qube2', 1);

            // Alarm => rood randje om de info-zone
            const anyAlarm =
              states['binary_sensor.glbal_qube2']?.state === 'on' ||
              states['binary_sensor.al_maxtime_antileg_active_qube2']?.state === 'on' ||
              states['binary_sensor.al_maxtime_dhw_active_qube2']?.state === 'on';
            const alarmStyle = anyAlarm ? 'box-shadow: inset 0 0 0 2px var(--error-color); border-radius: 8px;' : '';

            // ===== Kleur per status =====
            const colorByStatus = {
              0:'#000000', 1:'#1e88e5', 6:'#e53935',
              8:'#43a047', 9:'#43a047',
              15:'#9e9e9e', 16:'#9e9e9e',
              17:'#f9a825', 22:'#43a047'
            };
            const iconColor = colorByStatus[statusCode] ?? '#607d8b';

            // ===== Icoonkeuze volgens tabel =====
            const pickIcon = (code, service)=>{
              if ([0,1,6].includes(code)) return 'mdi:hot-tub'; // altijd SWW
              if (code === 22) return 'mdi:hot-tub';            // Heating DHW
              return (service === 'CV') ? 'mdi:radiator' : 'mdi:hot-tub';
            };
            const statusIcon = pickIcon(statusCode, serviceTxt);

            // ===== Gauge (klok) =====
            const SCALE_MIN = 20, SCALE_MAX = 70;
            const START_DEG = -120, END_DEG = 120;
            const CX = 60, CY = 60, R = 46;

            const clamp=(v,min,max)=>Math.max(min,Math.min(max,v));
            const mapTempToDeg=(T)=>{ if(T==null)return START_DEG; const p=(clamp(T,SCALE_MIN,SCALE_MAX)-SCALE_MIN)/(SCALE_MAX-SCALE_MIN); return START_DEG + p*(END_DEG-START_DEG); };
            const polarToXY=(cx,cy,r,deg)=>{ const rad=(deg-90)*Math.PI/180; return [cx+r*Math.cos(rad), cy+r*Math.sin(rad)]; };
            const arcPath=(cx,cy,r,d1,d2)=>{ const [x1,y1]=polarToXY(cx,cy,r,d1); const [x2,y2]=polarToXY(cx,cy,r,d2); const la=(Math.abs(d2-d1)>180)?1:0; const sw=d2>d1?1:0; return `M ${x1.toFixed(1)} ${y1.toFixed(1)} A ${r} ${r} 0 ${la} ${sw} ${x2.toFixed(1)} ${y2.toFixed(1)}`; };

            // Zones (vast)
            const A1s=START_DEG, A1e=mapTempToDeg(40); // geel
            const A2s=A1e,       A2e=mapTempToDeg(55); // groen
            const A3s=A2e,       A3e=mapTempToDeg(62); // donkergroen
            const A4s=A3e,       A4e=END_DEG;          // rood

            const needleDeg = mapTempToDeg(tDHWT ?? SCALE_MIN);
            const [nx, ny] = polarToXY(CX, CY, R-6, needleDeg);

            const C_Y='#f9a825', C_G='#43a047', C_DG='#1b5e20', C_R='#e53935', C_RING='#e0e0e0';

            // Linker status icoon (groot, verticaal gecentreerd)
            const colStatus = `
              <div style="flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; height:120px;">
                <ha-icon icon="${statusIcon}" style="color:${iconColor}; width:64px; height:64px;"></ha-icon>
              </div>
            `;

            // Gauge in het midden + temperatuur dichter tegen de gauge
            const dhwText = (tDHWT!=null) ? `${tDHWT.toFixed(1)} °C` : '...';
            const colDHW = `
              <div style="text-align:center; flex:1;">
                <svg width="120" height="120" viewBox="0 0 120 120">
                  <path d="${arcPath(CX,CY,R, START_DEG, END_DEG)}" stroke="${C_RING}" stroke-width="10" fill="none" />
                  <path d="${arcPath(CX,CY,R, A1s, A1e)}" stroke="${C_Y}" stroke-width="10" fill="none" />
                  <path d="${arcPath(CX,CY,R, A2s, A2e)}" stroke="${C_G}" stroke-width="10" fill="none" />
                  <path d="${arcPath(CX,CY,R, A3s, A3e)}" stroke="${C_DG}" stroke-width="10" fill="none" />
                  <path d="${arcPath(CX,CY,R, A4s, A4e)}" stroke="${C_R}" stroke-width="10" fill="none" />
                  ${[20,30,40,50,60,70].map(v=>{
                    const deg=mapTempToDeg(v); const [tx,ty]=polarToXY(CX,CY,R+8,deg);
                    return `<text x='${tx.toFixed(1)}' y='${ty.toFixed(1)}' text-anchor='middle' alignment-baseline='middle' font-size='10' fill='#555'>${v}</text>`;
                  }).join('')}
                  <circle cx="${CX}" cy="${CY}" r="4" fill="#555"/>
                  <line x1="${CX}" y1="${CY}" x2="${nx.toFixed(1)}" y2="${ny.toFixed(1)}" stroke="#111" stroke-width="3" stroke-linecap="round"/>
                </svg>
                <div style="margin-top:-16px; line-height:1; font-size:16px; font-weight:600;">${dhwText}</div>
              </div>
            `;

            // Wrapper met alarmstijl
            return `
              <div style="display:flex; align-items:center; justify-content:space-between; width:100%; ${alarmStyle}">
                <div style="display:flex; gap:8px; flex:1;">
                  ${colStatus}
                  ${colDHW}
                </div>
              </div>
            `;
          ]]]

Do you have this running on the latest version of HA?

I’m on 2025.8.3 currently. I’ve used this setup a couple years now, so I’ll be surprised if it doesn’t survive a jump to the current version.

Running you own event handlers is not recommended nor supported. v5 now supports icon actions, so that should simplify your config as you can remove one custom_field as the base button-card can have separate button and icon actions (home icon). You should not need custom html for gear, use a nested button-card with icon only and template display styles via state, and with v5.1.0 dev3 you can use multi-actions. html for your gauge looks fine.

v5 now also has button hover as well as press ripples so using button/icon actions you get good visuals.

Thinking about the best way to design, I would put your gauge in the main button, maybe using main button icon for home, then cog in a nested button as icon only, and perhaps the same for the home icon if not in main button.

To quickly fix the problem on a phone, you can change onclick to onpointerup. I’ve found that the latter option works on both pointer and touch devices.

Thanks for feedback and short / long term solutions.
I’m running

  • Installation method: Home Assistant OS
  • Core: 2025.9.4
  • Supervisor: 2025.10.0
  • Operating System: 15.0
  • Frontend: 20250903.5
  • button card: v5.0.2
    Will try your suggestions and report back

you can change onclick to onpointerup

changed onclick to onpointerup but unfortunately no change in behaviour. Still not working in HA-app on iPhone.

              <div style="position:relative; width:38px; height:38px; display:flex; align-items:center; justify-content:center; cursor:pointer;"
                   onpointerup="event.stopPropagation(); window.history.pushState(null,'','/huis/wp2-instelling'); window.dispatchEvent(new Event('location-changed'));">
                <ha-icon icon="mdi:cog" style="width:22px; height:22px; color: var(--primary-text-color);"></ha-icon>
                ${alarmDot}
              </div>

@dcapslock Followed your suggestion and this did the trick. Big thanks

2 Likes

So, I’m looking for some help as I cannot figure this one out myself.
I’m trying to make a momentary button that buzzes my front gate. This is my approach:

  - type: custom:button-card
    entity: switch.buzzer
    section_mode: true
    grid_options:
      rows: 2
      columns: 6
    icon: mdi:human-greeting
    size: 40px
    name: opener
    press_action:
      action: perform-action
      perform_action: switch.turn_on
      target:
        entity_id: switch.buzzer
    release_action:
      action: perform-action
      perform_action: switch.turn_off
      target:
        entity_id: switch.buzzer

The current behaviour is interesting (yet frustrating):

Both press_action as well as release_action work as expected on my Macbook. Effectively it created a momentary switch to buzz my gate.

However, on touch devices (my iPhone as well as a RPi with touchscreen) only release_action works to turn off the switch. Turning on via press_action has no effect via touch. No matter if going through a browser or companion app.

Any ideas?

The lack of press on a touch device is likely a bug. Suggest you post an issue on GitHub.

Try holding the the button on your phone a second longer. Do you get the desired results?

It’s most likely a cloud based delay…

Negative. It’s also not cloud based, all three devices were connected directly through wifi.

Alright, will do.

Guys, how to use the state_translated for an label for example?

this works:

- label: "[[[return `${states['sensor.roborock_qrevo_pro_status'].state}`]]]"

But this doesnt:

 - label: "[[[return `${state_translated['sensor.roborock_qrevo_pro_status'].state}`]]]"

i get this error:
button-card.js:482 ButtonCardJSTemplateError: state_translated is not defined

I wanto to use the more reabable state of a sensor like the ones below:

as in docs here:

Did you check in template editor of the developer tools if it’s working there?

Does anybody know, if _translated is available for state_attr also, e.g.
{{ state_attr(‘climate.ecobee’, ‘preset_mode’) }}

The object state_translated doesn’t exist in custom button, instead use helpers.localize, e.g.:

- label: "[[[return `${helpers.localize(states['sensor.roborock_qrevo_pro_status'].state)}`]]]"

Read here

2 Likes

take out the .state

- label: "[[[ return ${helpers.localize(states[‘sensor.roborock_qrevo_pro_status’])}]]]"

you can probably also do without the ${} and do

- label: "[[[ return helpers.localize(states['sensor.roborock_qrevo_pro_status']) ]]]"

1 Like

RomRider identified and fixed the bug, released in 7.0.0-dev.2.

1 Like

much appreciated!

Has there been a change to the custom button card in the last day, my code was working, but it now broke?

I created a custom button card with six areas, and until yesterday, Item 2 of the code was working fine, now there is an issue: The second item added an icon based on the weather state to the left of the temperatures, now it is just displaying the code. This is what I am seeing:

image

The suporting code (just including Item 2, and the overall card) is as follows:

type: custom:button-card
styles:
grid:
- grid-template-areas: “‘item1 item2 item3 item4 item5 item6’”
- grid-template-columns: 1fr 1fr 1fr 1fr 1fr 1fr
card:
- padding: 5px 0px
- background: none
- box-shadow: none
custom_fields:
item1:
- justify-self: center
item2:
- justify-self: center
item3:
- justify-self: center
item4:
- justify-self: center
item5:
- justify-self: center
item6:
- justify-self: center
custom_fields:
item2:
card:
type: custom:button-card
name: |
[[[
return states[‘sensor.hall_tri_sensor_temperature_2’].state + “°C (In)”
]]]
label: |
[[[ return states[‘sensor.gw2000a_v2_2_4_outdoor_temperature’].state
+ ‘°C (Out)
]]]
show_label: true
custom_fields:
icon: |
icon: |
[[[
var weather = states[‘weather.jhr_home’].state;
return ‘
]]]
styles:
grid:
- grid-template-areas: “"icon n" "icon l"”
card:
- overflow: visible
- background: none
- padding-right: 5px
- box-shadow: none
- border: none
name:
- justify-self: end
- font-size: 14px
- margin-right: 10px
- font-weight: 500
label:
- justify-self: end
- font-size: 20px
- font-weight: 700
- margin-right: 10px
custom_fields:
icon:
- justify-self: start
- align-self: start
- padding-right: 10px
- margin-bottom: “-22px”

The missing code where the icon is displayed returns the image source (/local/weather_icons/'+ weather + '.svg" width=“50” height=“50”). Any ideas?

Please format correctly , we can’t possibly check this