How to make scroll-time/speed and TranslateX string length dependent in css animation

had some iterations on this, never got around to finishing it, so here’s one more try. Main challenge:

make the marquee scroll, using a speed and translateX percentage , depending on the length of the marquee string. the space for the string is 36 characters.

type: custom:button-card
entity: sensor.marquee_alerts
variables:
  marquee: >
    [[[ return entity.attributes.marquee; ]]]
  length: >
    [[[ return Math.round(entity.attributes.marquee.length); ]]]
  time: >
    [[[ return variables.length/15 + 's'; ]]]
styles:

  custom_fields:
    marquee:
      - --scroll-time: >
          [[[ return variables.time; ]]]

custom_fields:
  marquee: >
    [[[ return `<p>${variables.marquee}</p>`; ]]]
extra_styles: |
  p {
    animation: scroll var(--scroll-time) linear infinite;
    animation-delay: 1s;
    /*animation-direction: reverse;*/
  }
  @keyframes scroll {
    0% { transform: translateX(95%); }
    100% { transform: translateX(-700%); }
  }

In the settings above this works nicely for a very long string

Jul-29-2024 10-55-53

but, if the string is only short, those numbers are incorrect, making speed and cost of all the long wait for the next run (-700%…) useless.

Jul-29-2024 11-49-05

meanwhile, my good old marquee element does it better (it just takes the string, and scrolls it in the available space continuously), albeit not as fluently

Jul-29-2024 11-49-36

cut is short: how to get a variable value inside

  @keyframes scroll {
    0% { transform: translateX(90%); }
    100% { transform: translateX('-' + ${var(--trans-x)} + '%' ); }
  }

or

  @keyframes scroll {
    0% { transform: translateX(90%); }
    100% { transform: "translateX( -" + var(--trans-x) + "% )"; }
  }

nothing works so far

and lets assume the length/space would do it

making that 344%
(which actually is quite alright if I enter that verbosely)

Bingo (I think)

can do this

  custom_fields:
    marquee:
      - --trans-x: >
          [[[ return '-' + 100*(variables.length/36) + '%' ; ]]]

and add that inside the keyframes:

  @keyframes scroll {
    0% { transform: translateX(90%); }
    100% { transform: translateX( var(--trans-x) ); }
  }

now need something like that for the scroll speed, which shouldn’t be as difficult to figure out, as it mostly is a personal preference, not as much a measurement to ‘fit exactly’ as the translateX is

guess I will start testing that using

      - --scroll-time: >
          [[[ return 20*variables.length/100 + 's'; ]]]

Believe this is final.

Yaml for custom:button-card
type: custom:button-card
entity: sensor.marquee_alerts
show_icon: false
tooltip: >
  [[[ return 'Lengte: ' + variables.length + 'Time: ' + variables.time; ]]]
variables:
  count: >
    [[[ return entity.state; ]]]
  phrase: >
    [[[ return entity.state == 1 ? 'Alert:' : 'Alerts:'; ]]]
  marquee: >
    [[[ return entity.attributes.marquee; ]]]
  length: >
    [[[ return Math.round(entity.attributes.marquee.length); ]]]
  snelheid: >
     [[[ return states['input_number.marquee_snelheid'].state; ]]]
  available: >
     [[[ return screen.width < 400 && screen.orientation.type == 'portrait-primary'
         ? 25 : 36; ]]]
styles:
  grid:
    - grid-template-areas: '"n marquee" '
    - grid-template-columns: max-content 1fr
  card:
    - background: var(--background-color-off)
    - color: var(--text-color-off)
    - font-size: 20px
    - font-weight: 400
    - padding: 12px
    - height: 48px
  name:
    - justify-self: left
    - text-align: center
    - padding-right: 4px
    - font-family: led_counter-7
  custom_fields:
    marquee:
      - font-family: led_counter-7
#       - transform: skewY(3deg)
      - text-align: center
#       - align-content: center
#       - justify-self: auto
#       - max-width: 100%
#       - overflow: hidden # doesnt have any effect?

# create local vars to inject in extra_styles
      - --scroll-time: >
          [[[ return variables.snelheid*variables.length/100 + 's'; ]]]
#       - animation: scroll 5s linear infinite # not here because overflows name/full button
      - --trans-x: >
          [[[ return '-' + 100*(variables.length/variables.available) + '%' ; ]]]
name: >
  [[[ return variables.count + ' ' + variables.phrase; ]]]
custom_fields:
  marquee: >
    [[[ return `<p>${variables.marquee}</p>`; ]]]
extra_styles: |
  p {
    animation: scroll var(--scroll-time) linear infinite;
    animation-delay: 1s;
    /*animation-direction: reverse;*/
  }
  @keyframes scroll {
    0% { transform: translateX(90%); }
    100% { transform: translateX( var(--trans-x) ); }
  }

had to create some extra calculations to be able to have the marquee behave correctly both on narrower iPhone or wider desktop available space in the button.
I also added an input_number, to set the speed of the marquee, and when set to 0, doesn’t scroll at all. (I believe the to be a bug (cant divide/multiply any 0) but in this case it works out nicely :wink: )

finally, I added an orientation check to the available variable, as the iOS app apparently does Not change the screen.width when changing that orientation.

I test those numbers with the tooltip:

tooltip: >
  [[[ return 'Lengte: ' + variables.length +
             'Time: ' + variables.snelheid +
             'Ruimte: ' + variables.available +
             'Screen: ' + screen.width +
             'Orientation: ' + screen.orientation.type; ]]]

Nicely done!

found this hass-config/config/dashboards/templates/button_card_templates/tpl_chips.yaml at c219b7081ef10313d1b14cea47daea336bd81045 · ngocjohn/hass-config · GitHub

whihc seems to be related to what I try to do.

still hoping to replace the

  - type: conditional
    conditions:
      - condition: screen
        media_query: "(min-width:400px)"
    card: !include /config/dashboard/includes/button/button_marquee_alerts_css_wide.yaml
  - type: conditional
    conditions:
      - condition: screen
        media_query: "(max-width:399px)"
      - condition: screen
        media_query: "(orientation:portrait)"
    card: !include /config/dashboard/includes/button/button_marquee_alerts_css_narrow.yaml

which copies the complete card except for the available space of the scroller, to something doing that in JS:

  available: >
     [[[ return window.matchMedia('(min-width:400px)').matches
         ? 36 : 25; ]]]

or

  available: >
     [[[ return window.matchMedia('(max-width:399px)').matches &&
                    window.matchMedia('(orientation:portrait)').matches
         ? 25 : 36; ]]]

doesnt work though, as rotating the screen doesnt trigger the template because, well no state changes state, only the view.

it is remarkable the frontend config conditional does work.
Can t we get that same responsiveness into the button card?

Hi, I quickly tested these conditions using variables. It is a 2 variables condition with boolean result… I think this will point you in the right direction.

But if you want it to dynamically trigger itself, you have to add an event listener for resize_event to the button template

template:

media_screen:
  variables:
    is_min_400: >
      [[[
        return window.matchMedia('(min-width:400px)').matches ? true : false;
      ]]]
    is_portait: >
      [[[
        return window.matchMedia('(orientation: portrait)').matches ? true : false;
      ]]]

button card:

      - type: custom:button-card
        entity: light.ceiling_lights
        show_name: false
        show_icon: false
        show_label: true
        triggers_update: all
        template:
          - media_screen
        label: >
          [[[ let is_min_400 = variables.is_min_400;
              let is_portrait = variables.is_portrait;
              let output = '';

              if (is_min_400 && is_portrait) {
                output = 'Portrait';
              } else if (is_min_400 && !is_portrait) {
                output = 'Landscape';
              } else {
                output = 'Mobile';
              }
              return output;
          ]]]

CleanShot 2024-08-03 at 19.13.03

1 Like

I know you have solved this problem, but just out of interest … I used a similar method for the marquee function, if the text width size is larger than the parent element, the animation marquee is applied and the speed is calculated with the result for the css variable --speed in the element card

speed css property

https://a.dropoverapp.com/cloud/download/3561c58e-08a2-4d36-8e03-e05998c83120/6f87b18f-495f-4f00-8d39-642b8550f2c0

1 Like

wow, nice, thanks a lot.

however, I cant make that happen, it always takes the ‘else’ …

even when I do this (and only use 1 variable):

variables:
  is_mobile: >
    [[[
      return window.matchMedia('(max-width:399px)').matches ? true : false;
    ]]]
  is_portait: >
    [[[
      return window.matchMedia('(orientation: portrait)').matches ? true : false;
    ]]]

tooltip: >
  [[[ return variables.is_mobile ? 'Mobile' : 'Not Mobile';
  ]]]

it always takes the else even when the screen is really less than 399px
btw, I hope I made myself clear this should happen on screen rotation itself, and not require a reload pull down of the mobile device.

even shorter test:

variables:
  is_mobile: >
    [[[
      return window.matchMedia('(max-width:399px)').matches;
    ]]]
  is_portait: >
    [[[
      return window.matchMedia('(orientation: portrait)').matches;
    ]]]

tooltip: >
  [[[ return 'Is mobile: ' + variables.is_mobile +
              'Portrait: ' + variables.is_portrait;
  ]]]

returns Undefined on both of the variables, which would explain the template always exiting the else

this works

tooltip: >
  [[[ return 'Is mobile: ' + window.matchMedia('(max-width:399px)').matches +
              'Portrait: ' + window.matchMedia('(orientation: portrait)').matches;
  ]]]

If you want to detect mobile devices, use combined conditions for true/false

window.matchMedia('(max-width: 400px) and (orientation: portrait)')

This checks if the viewport width is 400 pixels or less and if the orientation is portrait.

media_screen:
  variables:
    is_min_400: >
      [[[
        return window.matchMedia('(min-width:400px)').matches ? true : false;
      ]]]
    is_portait: >
      [[[
        return window.matchMedia('(orientation: portrait)').matches ? true : false;
      ]]]
    is_mobile_portrait: >
      [[[
        return window.matchMedia('(max-width: 400px) and (orientation: portrait)').matches;
      ]]]

        label: >
          [[[ let is_min_400 = variables.is_min_400;
              let is_portrait = variables.is_portrait;
              let is_mobile_portrait = variables.is_mobile_portrait;
              let output = is_mobile_portrait ? 'Mobile Portrait' : "Not Mobile Portrait";

              return output;
          ]]]

for a more accurate result, feel free to add a window navigator property

ha, thanks, but still doesnt work… did you test this on a mobile device? it always returns the else, in this case Not Mobile Portrait…

I seem to remember set about decalirnf those variable and their order, not sure anymore.

However, I can probably use the syntax above, where I use the window.matchMedia directly, and not set it to a variable


Aargh, I am an idiot!!!
I forgot to add them to my already existing variables, so had 2 variables: sections…
let me go back to your first solution

so, looking for mobile portrait, mobile landscape, or Desktop.
Ill be back thx

for dynamic card display I use layout-card and set view layout, I think it’s much easier than customizing button template.

yes, I believe its not possible otherwise. check:

tooltip: >
  [[[ return window.matchMedia('(orientation:portrait)').matches; ]]]

this will only update on reload of the view, not directly on rotating of the device.
its because the card only updates upon states change, and there is non tru state changing here.
Ive FR’d the addition of a new entity in the mobile_app for this purpose, I hope it will see the light of day. Please +1 that if you’d like it

Btw,Ive also opened a FR to add these properties to triggers_update option of the custom:button-card

I already mentioned for dynamic changes you need to add an eventlistener. is a little laggy when record screen, otherwise responds right away…without eventlisterner, there will be longer delay for render…

CleanShot 2024-08-04 at 00.29.42

media_screen:
  triggers_update: all
  show_name: false
  show_icon: false
  show_label: true
  show_state: false
  variables:
    is_min_400: >
      [[[
        return window.matchMedia('(min-width:400px)').matches ? true : false;
      ]]]
    is_portait: >
      [[[
        return window.matchMedia('(orientation: portrait)').matches ? true : false;
      ]]]
    is_mobile_portrait: >
      [[[
        return window.matchMedia('(max-width:400px) and (orientation: portrait)').matches ? true : false;
      ]]]
  styles:
    label:
      - text-transform: uppercase
      - color: >
          [[[
            return variables.is_portait ? 'red' : 'blue';
          ]]]
      - justify-self: center
      - font-weight: bold
  label: >
    [[[
      let elt = this.shadowRoot;
      if (elt) {
        let output = '';

        const updateOutput = () => {
          let isPortrait = window.matchMedia('(orientation: portrait)').matches;
          output = isPortrait ? 'Portrait' : 'Landscape';
          this.requestUpdate(); // Request an update to the card
        };

        // Initial orientation check
        updateOutput();

        // Adding event listener for orientation change
        window.addEventListener('orientationchange', () => {
          console.log('Orientation changed');
          updateOutput();
        });
        return output;
      }
    ]]]

- type: custom:button-card
  entity: light.ceiling_lights
  template:
    - media_screen

right, I must be daft once more. Ive created this button-card-template:

media_screen:
  variables:
    is_min_400: >
      [[[
        return window.matchMedia('(min-width:400px)').matches ? true : false;
      ]]]
    is_mobile_portrait: >
      [[[
        return window.matchMedia('(max-width:390px) and (orientation: portrait)').matches ? true : false;
      ]]]
    is_portait: >
      [[[
        return window.matchMedia('(orientation: portrait)').matches ? true : false;
      ]]]
    output: >
      [[[
        let elt = this.shadowRoot;
        if (elt) {
          let output = '';

          const updateOutput = () => {
            let isPortrait = window.matchMedia('(orientation: portrait)').matches;
            output = isPortrait ? 'Portrait' : 'Landscape';
            this.requestUpdate(); // Request an update to the card
          };

          // Initial orientation check
          updateOutput();

          // Adding event listener for orientation change
          window.addEventListener('orientationchange', () => {
            console.log('Orientation changed');
            updateOutput();
          });
          return output;
        }
      ]]]

and added it to the button with the scroller

type: custom:button-card
entity: sensor.marquee_alerts
template:
  - styles_tooltip
  - media_screen
tooltip: >
  [[[ return 'Output: ' + variables.output; ]]]

Note Ive correctly alphabetized the variables, in order for them to be able to nest.

This now shows the output in the tooltip. So the syntax is correct, and properly outputs the result (didnt yet check all of the details I need there, but this is just to get it going)

However, still not listening to auto screen size changes or orientation, until I pull to refresh/reload the view on desktop.

After adding the

triggers_update: all

to the button-card-template, things start to finally work.
I hate to use that triggers_update option though, as it, well, triggers on all updates, so is other expensive.

It’s the reason I set only these variables inside that media_screen template, hoping it will only update on those. otoh, the template is used in the main button, so to will also push the updates to all of them…

not sure if this is the most efficient way, but. I do have something to work with now.

as for the console-log: I cant see anything being logged in Inspector…

update

as a matter of fact, the triggers_update: all seems to not require the event listener because when I add this template:

media_screen:
  triggers_update: all
  variables:
    is_min_400: >
      [[[
        return window.matchMedia('(min-width:400px)').matches ? true : false;
      ]]]
    is_mobile_portrait: >
      [[[
        return window.matchMedia('(max-width:390px) and (orientation: portrait)').matches ? true : false;
      ]]]
    is_portrait: >
      [[[
        return window.matchMedia('(orientation: portrait)').matches ? true : false;
      ]]]
    is_landscape: >
      [[[
        return window.matchMedia('(orientation: landscape)').matches ? true : false;
      ]]]

I can see the tooltip change upon orientation change immediately. That ofc makes things a lot easier. (If we forget the efficiency loss of the triggers_update: all …)

and so this reaches a solution,

since I could Not after all of the above use the variables.is_landscape in the template, I ended up trying this:

  available: >
     [[[ return window.matchMedia('(max-width:390px) and (orientation: portrait)').matches ? 25 : 36; ]]]

combined with the triggers_update: all and bingo, that’s all that is needed. pasting the documentation in that here GitHub - custom-cards/button-card: ❇️ Lovelace button-card for home assistant

seems brute force, but alas, cant be done in any other way, to the best of my experimenting.
Its either that, or a double button-card with visibility set/conditional

and yet there is an evolution…

css Marquee

(nevermind the choppiness here, thats because of reduce screen resolution of Giphy, it’s completely fluent in the real life View/Dashboard)

Ive dropped the complete ‘Marquee’ button inside a decluttering template, and make the core Frontend decide which orientation and as a consequence which available space there is for the Marquee:

type: vertical-stack
visibility:
  - condition: state
    entity: binary_sensor.alerts
    state: 'on'
cards:

  - type: conditional
    conditions:
      - condition: screen
        media_query: '(min-width:400px)'
    card:
      type: custom:decluttering-card
      template: marquee
      variables:
        - available: 36

  - type: conditional
    conditions:
      - condition: screen
        media_query: '(max-width:390px)'
      - condition: screen
        media_query: '(orientation:portrait)'
    card:
      type: custom:decluttering-card
      template: marquee
      variables:
        - available: 25

  - type: grid
    columns: 4
    cards: !include /config/dashboard/buttons/buttons_alerts.yaml

the decluttering template holds the complete Marquee:

Complete code inside the decluttering to be easily injected in the vertical-stack
card:
  type: custom:button-card
  entity: sensor.marquee_alerts
  template: styles_tooltip
  show_icon: false
#   triggers_update: all #no longer required, because of the visibility check on the containing vertical-stack
  variables:
    count: >
      [[[ return entity.state; ]]]
    phrase: >
      [[[ return entity.state == 1 ? 'Alert:' : 'Alerts:'; ]]]
    marquee: >
      [[[ return entity.attributes.marquee; ]]]
    length: >
      [[[ return Math.round(entity.attributes.marquee.length); ]]]
    snelheid: >
       [[[ return states['input_number.marquee_snelheid'].state; ]]]
    available: '[[available]]'
  styles:
    grid:
      - grid-template-areas: '"n marquee" '
      - grid-template-columns: max-content 1fr
    card:
      - background: var(--background-color-off)
      - color: var(--text-color-off)
      - font-size: 20px
      - font-weight: 400
      - padding: 12px
      - height: 48px
    name:
      - justify-self: left
      - text-align: center
      - padding-right: 4px
      - font-family: led_counter-7
    custom_fields:
      marquee:
        - font-family: led_counter-7
        - text-align: center

  # create local vars to inject in extra_styles
        - --scroll-time: >
            [[[ return variables.snelheid*variables.length/100 + 's'; ]]]
  #       - animation: scroll 5s linear infinite # not here because overflows name/full button
        - --trans-x: >
            [[[ return '-' + 100*(variables.length/variables.available) + '%' ; ]]]
  name: >
    [[[ return variables.count + ' ' + variables.phrase; ]]]
  custom_fields:
    marquee: >
      [[[ return `<p>${variables.marquee}</p>`; ]]]
  extra_styles: |
    p {
      animation: scroll var(--scroll-time) linear infinite;
      animation-delay: 1s;
      /*animation-direction: reverse;*/
    }
    @keyframes scroll {
      0% { transform: translateX(90%); }
      100% { transform: translateX( var(--trans-x) ); }
    }

and the sensor.marquee_alerts is just a huge template sensor building on individual alerts

Actual Marquee with all texts that need to be scrolling by
template:

  - sensor:

      - unique_id: marquee_alerts
        state: >
          {{states('sensor.alerts_notifying')|int(0)}}
        icon: >
          mdi:{{'check' if is_state('sensor.alerts_notifying','0') else 'alert'}}-circle
        attributes:
          marquee: >
            {%- if is_state('binary_sensor.alerts','on') -%}
              {%- if is_state('binary_sensor.config_notifications','on') %} - Updates of repairs actief{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.rookmelder','on') %} - Rook gesignaleerd, controleer keuken, zolder en/of stookhok!!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.co_alert','on') %} - CO gemeten, controleer zolder en/of stookhok!!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.watermeter_leak_detected','on') %} - Water lekkage, controleer of er een kraan open staat!!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.alarm_triggered','on') -%} - Alarm getriggered, controleer!!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.flood_alert','on') -%} - Overstroming gesignaleerd: {{state_attr('sensor.flood_sensors','status')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.garage_deur','on') -%} - Garage deur is open, controleer camera{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.schuifpuien','on') -%} - {{state_attr('sensor.schuifpuien_samenvatting','tekst')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.deuren','on') -%} - {{state_attr('sensor.deuren_samenvatting','tekst')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.ramen','on') -%} - {{state_attr('sensor.ramen_samenvatting','tekst')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.klimaat_woonkamer_alert','on') -%} - Klimaat woonkamer: Ventileer!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.schimmel_alert','on') -%} - Schimmel alert: {{states('sensor.schimmel_sensor')|float(0)}}% / CO2: {{states('sensor.co2_woonkamer')}} ppm{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.luchtvochtigheid_woonkamer_laag','on') -%} - Luchtvochtigheid in de woonkamer is te laag: {{states('sensor.luchtvochtigheid_woonkamer')}} %{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.uv_alert','on') -%} - Uv index: {{state_attr('binary_sensor.uv_alert','uv_index')}}, maximaal {{state_attr('binary_sensor.uv_alert','maximaal_in_zon')}} minuten in de zon!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.meteoalarm_brabant','on') -%} - MeteoAlarm: {{state_attr('binary_sensor.meteoalarm_brabant','headline').split(' -')[0]}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.earthquakes_near_active','on') -%} - Opgepast, aardbeving in de buurt: {{state_attr('sensor.earthquakes_near','list')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.lightning_nearest_below_range','on') -%} - Opgepast, bliksem: {{states('sensor.lightning_strikes_near')}} inslag{{'en' if states('sensor.lightning_strikes_near')|int(0) != 1}} in de buurt, ga naar binnen!{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.espresso_needs_refill','on') -%} - Espresso {{states('sensor.espresso')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.hubs_offline','on') -%} - {{state_attr('sensor.hubs_samenvatting','message')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.kritieke_schakelaars_offline','on') -%} - {{state_attr('sensor.kritieke_schakelaars_samenvatting','message')}}{{'\n'}}{%- endif -%}
              {%- if is_state('binary_sensor.afvalwijzer_notificatie','on') -%} - {{states('sensor.afval_alert')}}{{'\n'}}{%- endif -%}


            {%- else -%} Geen actieve waarschuwingen
            {%- endif -%}