I have a heavily customized button-card template with a circular slider, exactly the one tamper evident on youtube created for his dashboard.
For lights, drag-to-change brightness works perfectly.
For fans (Tuya integration), I can see percentage and change speed from the HA more-info popup,
but the same circular slider does not allow dragging to change fan speed.
Facts:
- Fan entity has attributes:
percentage, percentage_step - fan.set_percentage works from popup
- circle SVG renders and % text updates
- cursor never changes to grab
- dragging does nothing
Question:
What is the correct way to enable drag interaction for fan entities in button-card?
Is there a frontend restriction for fan sliders compared to light brightness sliders?
Any insight into how the HA frontend handles fan sliders internally would help.
Since, I really don’t know where the error is coming from, i have added the code where i think issue can be.
extra_styles:
extra_styles: |
[[[
if (entity) {
if (entity.entity_id.split('.')[0] === 'light' && variables.state_on) {
// theme variable and conditions
let style = getComputedStyle(document.body),
theme_var = style.getPropertyValue('--button-card-light-color-temp'),
is_hsl = theme_var.startsWith('hsl('),
is_color_temp = entity.attributes.color_mode === 'color_temp';
if (is_hsl && is_color_temp && entity.attributes.brightness) {
// calculate lightness in hsl
let regex_pattern = /(\d+)(?!.*\d)/g,
brightness = entity.attributes.brightness / 2.54,
lightness = parseFloat(theme_var.match(regex_pattern)[0]),
min = lightness - 10,
max = lightness + 10,
calc_lightness = brightness * (max - min) / 100 + min;
var light_color = theme_var.replace(regex_pattern, calc_lightness);
}
else {
var light_color = 'var(--button-card-light-color)';
}
}
}
return `
/* * * * * * * * * * * * * * * * * *
* *
* LIGHT *
* *
* * * * * * * * * * * * * * * * * */
svg {
--light-color: ${
variables.state_on && entity.attributes.brightness
? light_color
: variables.state_on && !entity.attributes.brightness
? 'var(--state-icon-active-color);'
: 'var(--state-icon-color);' }
}
.light-color {
fill: var(--light-color);
transition: all 0.25s ease-out;
}
/* magnification */
:host {
--card-portrait: 1.4;
--card-phone: 2.271;
}
${this._config.template.includes('light') ? `
/* * * * * * * * * * * * * * * * * *
* *
* CIRCLE SLIDER *
* *
* * * * * * * * * * * * * * * * * */
#circle_slider {
opacity: 0;
appearance: none;
transform: rotate(270deg);
width: 90%;
position: absolute;
pointer-events: none;
cursor: grab;
left: 26%;
margin-top: 13%;
}
#circle_slider::-webkit-slider-thumb {
pointer-events: initial;
appearance: none;
width: 3vw;
height: 3vw;
border-radius: 50%;
background: green;
}
#circle_slider::-webkit-slider-runnable-track {
background: cornflowerblue;
}
#circle_slider::-moz-range-thumb {
pointer-events: initial;
appearance: none;
width: 3vw;
height: 3vw;
border-radius: 50%;
background: green;
}
#circle_slider::-moz-range-track {
background: cornflowerblue;
height: 3vw;
}
/* portrait */
@media screen and (max-width: 1200px) {
#circle_slider::-webkit-slider-thumb {
width: 4vw;
height: 4vw;
}
#circle_slider::-moz-range-thumb {
width: 4vw;
height: 4vw;
}
}
/* phone */
@media screen and (max-width: 800px) {
#circle_slider::-webkit-slider-thumb {
width: 5.8vw;
height: 5.8vw;
}
#circle_slider::-moz-range-thumb {
width: 5.8vw;
height: 5.8vw;
}
}
`:''}
/* * * * * * * * * * * * * * * * * *
* *
* BASE *
* *
* * * * * * * * * * * * * * * * * */
#container {
text-align: left !important;
z-index: 1;
}
#card {
padding: 10.9% 9.9% 8.9% 10.9%;
}
#state::first-letter {
text-transform: uppercase;
}
#name, #state {
font-size: var(--button-card-font-size);
font-weight: var(--button-card-font-weight);
letter-spacing: var(--button-card-letter-spacing);
}
/* portrait */
@media screen and (max-width: 1200px) {
#name, #state {
font-size: calc(var(--button-card-font-size) * var(--card-portrait));
}
}
/* phone */
@media screen and (max-width: 800px) {
#name, #state {
font-size: calc(var(--button-card-font-size) * var(--card-phone));
}
}
${variables.tilt_enable === true ? `
/* * * * * * * * * * * * * * * * * *
* *
* TILT *
* *
* * * * * * * * * * * * * * * * * */
#name, #state {
font-size: calc(var(--button-card-font-size) - var(--z-axis-adjustment));
}
/* portrait */
@media screen and (max-width: 1200px) {
#name, #state {
font-size: calc(calc(var(--button-card-font-size) * var(--card-portrait)) - var(--z-axis-adjustment));
}
}
/* phone */
@media screen and (max-width: 800px) {
#name, #state {
font-size: calc(calc(var(--button-card-font-size) * var(--card-phone)) - var(--z-axis-adjustment));
}
}
#container {
transform: translateZ(${variables.tilt_options.parallax});
}
#circle_slider {
width: 100%;
margin-top: 0;
}
/* adjust circle_slider position for firefox */
@supports (-moz-appearance:none) {
#circle_slider {
margin-top: 13%;
}
}
#card {
padding: 12% 11% 10.5% 12%;
transform-style: preserve-3d;
overflow: visible;
/* firefox pixelated edges */
outline: 1px solid transparent;
}
#ripple, .js-tilt-glare {
clip-path: inset(0 round var(--button-card-border-radius));
overflow: hidden;
}
.js-tilt-glare {
z-index: 1;
}
.js-tilt-glare-inner {
background-color: rgba(0,0,0,0.9);
}
`:''}
${this._config.template.includes('conditional_media') ? `
/* * * * * * * * * * * * * * * * * *
* *
* MEDIA *
* *
* * * * * * * * * * * * * * * * * */
:host {
--blur-intensity: blur(4.5px) brightness(0.8);
}
/* phone */
@media screen and (max-width: 800px) {
:host {
--blur-intensity: blur(2.5px) brightness(0.8);
}
}
#ripple, .js-tilt-glare {
clip-path: inset(0 round calc(var(--button-card-border-radius) / 2));
}
#container {
overflow: hidden;
}
.marquee {
animation: marquee 20s linear infinite;
}
@keyframes marquee {
from {
transform: translateX(0%);
}
to {
transform: translateX(-50%);
}
}
`:''}
${this._config.template.includes('footer') ? `
/* * * * * * * * * * * * * * * * * *
* *
* FOOTER *
* *
* * * * * * * * * * * * * * * * * */
/* magnification */
:host {
--footer-portrait: 1.4;
--footer-phone: 2.8;
}
#ripple, .js-tilt-glare {
border-radius: calc(var(--footer-card-border-radius) - 0.1vw);
clip-path: inset(0 round calc( var(--button-card-border-radius) - 0.1vw ));
}
#name {
font-size: var(--footer-card-font-size);
padding: var(--footer-card-padding-v) var(--footer-card-padding-h);
letter-spacing: 0.05vw;
}
ha-icon {
width: var(--footer-card-icon-size);
vertical-align: 7%;
padding-right: 0.1vw;
opacity: 0.4;
}
#card {
border-radius: var(--footer-card-border-radius);
background: rgba(115, 115, 115, 0.10);
}
#notify {
font-size: var(--footer-notify-font-size);
width: var(--footer-notify-box-size);
height: var(--footer-notify-box-size);
line-height: var(--footer-notify-box-size);
padding-right: 0.5%;
padding-top: 0.5%;
top: var(--footer-notify-top);
right: var(--footer-notify-right);
}
/* portrait */
@media screen and (max-width: 1200px) {
#name {
font-size: calc(var(--footer-card-font-size) * var(--footer-portrait));
padding: calc(var(--footer-card-padding-v) * var(--footer-portrait)) calc(var(--footer-card-padding-h) * var(--footer-portrait));
}
ha-icon {
width: calc(var(--footer-card-icon-size) * var(--footer-portrait));
}
#card {
border-radius: calc(var(--footer-card-border-radius) * var(--footer-portrait));
margin: 0 0.5vw;
}
#notify {
font-size: calc(var(--footer-notify-font-size) * var(--footer-portrait));
width: calc(var(--footer-notify-box-size) * var(--footer-portrait));
height: calc(var(--footer-notify-box-size) * var(--footer-portrait));
line-height: calc(var(--footer-notify-box-size) * var(--footer-portrait));
}
}
/* phone */
@media screen and (max-width: 800px) {
#name {
font-size: calc(var(--footer-card-font-size) * var(--footer-phone));
padding: calc(var(--footer-card-padding-v) * var(--footer-phone)) calc(var(--footer-card-padding-h) * var(--footer-phone));
letter-spacing: 0.05vw;
}
ha-icon {
width: calc(var(--footer-card-icon-size) * var(--footer-phone));
}
#card {
border-radius: calc(var(--footer-card-border-radius) * var(--footer-phone));
background: rgba(115, 115, 115, 0.12);
margin: 0 0.5vw;
}
#notify {
font-size: calc(var(--footer-notify-font-size) * var(--footer-phone));
width: calc(var(--footer-notify-box-size) * var(--footer-phone));
height: calc(var(--footer-notify-box-size) * var(--footer-phone));
line-height: calc(var(--footer-notify-box-size) * var(--footer-phone) + 1px);
top: calc(var(--footer-notify-top) * var(--footer-phone));
right: calc(var(--footer-notify-right) * var(--footer-phone) + 2%);
padding: 0;
}
}
`:''}
`;
]]]
card_light:
show_state: true
state_display: null
size: 40%
styles:
card:
- width: 120px
- height: 120px
grid:
- grid-template-areas: '"i" "n" "s"'
- grid-template-columns: 1fr
- grid-template-rows: 1fr min-content min-content
icon:
- color: var(--button-card-light-color)
img_cell:
- align-self: start
- text-align: start
- color: var(--button-card-light-color)
name:
- color: var(--button-card-light-color)
- justify-self: start
- padding-left: 10px
- font-weight: bold
- font-size: 11pt
state:
- justify-self: start
- padding-left: 10px
- text-transform: capitalize
- font-size: 11pt
- color: var(--button-card-light-color)
state:
- value: 'off'
styles:
card:
- filter: opacity(70%)
icon:
- filter: grayscale(100%)
hold_action:
action: more-info
light:
template:
- base
- circle
- loader
double_tap_action:
action: more-info
variables:
circle_input: |
[[[
if (entity) {
// if light group get brightness from child to remove bounce
let child = entity.attributes.entity_id,
brightness = child && states[child[0]].attributes.brightness
? Math.round(states[child[0]].attributes.brightness / 2.54)
: Math.round(entity.attributes.brightness / 2.54);
return brightness === 0 && entity.state !== 'off'
? 1
: brightness
}
]]]
circle_input_unit: '%'
base:
template:
- settings
- tilt
- extra_styles
variables:
state_on: >
[[[ return ['on', 'home', 'cool', 'heat', 'fan_only', 'playing',
'unlocked', 'armed_home', 'armed_away', 'armed_night',
'open'].indexOf(!entity || entity.state) !== -1; ]]]
state: |
[[[ return !entity || entity.state; ]]]
entity_id: |
[[[ return !entity || entity.entity_id; ]]]
entity_picture: |
[[[ return !entity || entity.attributes.entity_picture; ]]]
timeout: |
[[[ return !entity || Date.now() - Date.parse(entity.last_changed); ]]]
is_youtube: |
[[[
let is_youtube = entity?.attributes?.app_id === 'com.google.ios.youtube',
sensor = this?._config?.triggers_update,
media_title = entity?.attributes?.media_title,
watching_title = states[sensor]?.attributes?.title;
if (is_youtube && media_title === watching_title) {
return true;
}
]]]
aspect_ratio: 1/1
show_state: true
show_icon: true
state_display: |
[[[
const stateDict = {
'on': variables.translate_on,
'off': variables.translate_off,
'cool': variables.translate_cool,
'fan_only': variables.translate_fan_only,
};
if (variables.state === true) return variables.translate_unknown;
return stateDict[variables.state];
]]]
tap_action:
ui_sound_tablet: |
[[[
let screensaver = states[variables.entity_tablet] === undefined ||
states[variables.entity_tablet].state;
if (variables.state === 'off' && screensaver === 'off') {
hass.callService('media_player', 'play_media', {
entity_id: variables.entity_browser_mod,
media_content_id: '/local/sound/on.m4a',
media_content_type: 'music'
});
}
if (variables.state_on && screensaver === 'off') {
hass.callService('media_player', 'play_media', {
entity_id: variables.entity_browser_mod,
media_content_id: '/local/sound/off.m4a',
media_content_type: 'music'
});
}
]]]
card_bounce: |
[[[
// add animation
if (this.getElementsByTagName("style").length === 0) {
// phone condition
let mq = window.matchMedia('(max-width: 800px)').matches;
let style = document.createElement('style');
style.innerHTML = `
@keyframes card_bounce {
0% { transform: scale(1); }
10% { transform: scale(${ mq ? '0.92' : '0.94' }); }
25% { transform: scale(1); }
30% { transform: scale(${ mq ? '0.96' : '0.98' }); }
100% { transform: scale(1); }
}
`;
this.appendChild(style);
}
// duration
let duration = 800;
// animate
this.style.animation = `card_bounce ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`;
// reset
window.setTimeout(() => { this.style.animation = "none"; }, duration + 100)
]]]
action: toggle
hold_action:
action: block
state:
- value: unavailable
styles:
card:
- opacity: 45%
tap_action:
action: none
- value: 'off'
styles:
icon:
- color: rgb(215,215,215)
- value: closed
styles:
icon:
- color: rgb(215,215,215)
- value: paused
styles:
icon:
- color: rgb(215,215,215)
styles:
grid:
- grid-template-areas: |
"i circle"
"n n"
"s s"
- grid-template-columns: repeat(2, 1fr)
- grid-template-rows: auto repeat(2, min-content)
- gap: 1.3%
- align-items: start
- will-change: transform
name:
- justify-self: start
- line-height: 121%
- font-weight: 700
state:
- justify-self: start
- line-height: 115%
icon:
- color: var(--button-card-light-color)
- icon_size: 600px
- padding: 0px 0px
- margin-top: '-45%'
- margin-left: '-28%'
card:
- border-radius: 22px
- border-width: 0
- '-webkit-tap-highlight-color': rgba(0,0,0,0)
- transition: none
- '--mdc-ripple-color': |
[[[
return variables.state_on
? 'rgb(0, 0, 0)'
: '#97989c';
]]]
- color: |
[[[
return variables.state_on
? '#4b5254'
: '#97989c';
]]]
- background-color: |
[[[
return variables.state_on
? 'rgba(255, 255, 255, 0.85)'
: 'var(--tile-background-color)';
]]]
circle:
styles:
card:
- '--c-stroke-color-on': '#b0b0b0'
- '--c-stroke-color-off': none
- '--c-fill-color-on': none
- '--c-fill-color-off': rgba(255,255,255,0.04)
- '--c-stroke-width': 2.3
- '--c-stroke-width-dragging': 4
- '--c-font-color': '#97989c'
- '--c-font-size': 14px
- '--c-unit-font-size': 10.5px
- '--c-font-weight': 700
- '--c-letter-spacing': '-0.02rem'
custom_fields:
circle:
- display: initial
- width: 88%
- margin: '-3% 2% 0 0'
- justify-self: end
- opacity: 1
custom_fields:
circle: |
[[[
if (entity) {
let r = 22.1,
c = r * 2 * Math.PI,
tspan = '<tspan dx=".2" dy="-.4">',
domain = entity.entity_id.split('.')[0],
state = variables.state_on,
input =
domain === 'fan'
? entity.attributes.percentage ?? 0
: variables.circle_input || ' ',
unit =
domain === 'fan'
? '%'
: variables.circle_input_unit || ' ';
/* * * * * * * * * * * * * * * * * *
* *
* CIRCLE *
* *
* * * * * * * * * * * * * * * * * */
let circle = (state, input, unit) => {
return `
<svg viewBox="0 0 50 50">
<style>
circle {
transform: rotate(-90deg);
transform-origin: 50% 50%;
stroke-dasharray: ${c};
stroke-dashoffset: ${typeof input === 'number' && c - input / 100 * c};
stroke-width: var(--c-stroke-width);
stroke: ${state ? 'var(--c-stroke-color-on)' : 'var(--c-stroke-color-off)'};
fill: ${state ? 'var(--c-fill-color-on)' : 'var(--c-fill-color-off)'};
}
text {
font-size: var(--c-font-size);
font-weight: var(--c-font-weight);
letter-spacing: var(--c-letter-spacing);
fill: var(--c-font-color);
}
tspan {
font-size: var(--c-unit-font-size);
}
#circle_value, tspan {
text-anchor: middle;
dominant-baseline: central;
}
</style>
<circle id="circle_stroke" cx="25" cy="25" r="${r}"/>
<text id="circle_value" x="50%" y="52%">${input}${tspan}${unit}</tspan></text>
</svg>
${(domain === 'light' || domain === 'fan') && `
<input id="circle_slider"
type="range"
min="0"
max="100"
step="${domain === 'fan'
? (entity.attributes.percentage_step || 1)
: 1}"
value="${input}">
`}
`;
}
/* * * * * * * * * * * * * * * * * *
* *
* LIGHT *
* *
* * * * * * * * * * * * * * * * * */
if (domain === 'light' && state) {
// wait 0ms for shadow dom
setTimeout(() => {
// then get elements
let elt = this.shadowRoot,
circle_slider = elt.getElementById('circle_slider'),
circle_value = elt.getElementById('circle_value'),
circle_stroke = elt.getElementById('circle_stroke');
// approximate position of thumb relative to circle
circle_slider.style.top = `${(circle_slider.value - 50) / 1.66 - 1}%`;
// debug position
let debug = false;
if (debug) circle_slider.style.opacity = 0.3;
// pass each event to handler
['click', 'input', 'mousedown', 'mouseup', 'touchstart', 'touchend'].forEach((event) => {
circle_slider.addEventListener(event, handler, { passive: true })
});
function handler(event) {
// "this" refers to slider
if (event.target === this) {
// bypass button-card tap_action
event.stopPropagation();
// update circle_value
circle_value.innerHTML = `${this.value}${tspan}${unit}</tspan>`;
// update stroke
circle_stroke.style.strokeDashoffset = c - this.value / 100 * c;
circle_stroke.style.strokeWidth = 'var(--c-stroke-width-dragging)';
// set cursor while dragging
if (event.type === 'mousedown' || event.type === 'input') {
this.style.cursor = 'grabbing';
} else {
this.style.cursor = 'grab';
}
// reset stroke width if value doesn't change
if (input == this.value && (event.type === 'click' || event.type === 'touchend'))
circle_stroke.style.strokeWidth = 'var(--c-stroke-width)';
// on release
if (event.type === 'mouseup' || event.type === 'touchend') {
// display loader if brightness is 0
if (circle_slider.value == 0 && elt.getElementById('loader')) {
elt.getElementById('loader').style.display = 'initial';
elt.getElementById('circle').style.display = 'none';
}
// set brightness
hass.callService('light', 'turn_on', {
entity_id: entity.entity_id,
brightness_pct: this.value
});
}
}
}
}, 0);
return circle(state, input, unit);
}
/* * * * * * * * * * * * * * * * * *
* *
* PERSON *
* *
* * * * * * * * * * * * * * * * * */
else if (domain === 'person') {
let time = c => {
let s = (c/1e3),
m = (c/6e4),
h = (c/36e5),
d = (c/864e5);
return s < 60
? parseInt(s) + 's'
: m < 60 ? parseInt(m) + 'm'
: h < 24 ? parseInt(h) + 'h'
: parseInt(d) + 'd';
};
let input = states[variables.retain] === undefined || states[variables.retain].state === 'unavailable'
? time(Date.now() - Date.parse(entity.last_changed))
: time(Date.now() - Date.parse(states[variables.retain].state)),
unit = ' ';
return circle(state, input, unit);
}
/* * * * * * * * * * * * * * * * * *
* *
* CLIMATE *
* *
* * * * * * * * * * * * * * * * * */
else if (domain === 'fan' && state) {
setTimeout(() => {
const elt = this.shadowRoot;
const slider = elt.getElementById('circle_slider');
const circle_value = elt.getElementById('circle_value');
const circle_stroke = elt.getElementById('circle_stroke');
if (!slider) return;
// ENABLE interaction
slider.style.pointerEvents = 'auto';
const updateUI = (val) => {
circle_value.innerHTML = `${val}${tspan}%</tspan>`;
circle_stroke.style.strokeDashoffset = c - val / 100 * c;
slider.style.cursor = 'grabbing';
};
// LIVE UI update while dragging
slider.addEventListener('input', (e) => {
updateUI(e.target.value);
});
// APPLY SPEED ONLY ON RELEASE
slider.addEventListener('mouseup', apply);
slider.addEventListener('touchend', apply);
function apply(e) {
slider.style.cursor = 'grab';
hass.callService('fan', 'set_percentage', {
entity_id: entity.entity_id,
percentage: Number(e.target.value)
});
}
}, 0);
return `
${circle(state, entity.attributes.percentage ?? 0, '%')}
<input id="circle_slider"
type="range"
min="0"
max="100"
step="${entity.attributes.percentage_step || 1}"
value="${entity.attributes.percentage ?? 0}">
`;
}
```

