Horizontal layout/scrolling of cards

is there a way to layout set of vertical stacks straight horizontal? with horizontal scroll?

I don’t think so. You can create a row of vertical stacks by putting them inside a horizontal stack, but HA will compress the contents of each card to fit them all in.

There is a HACS frontend feature called Home Assistant Swipe Navigation which allows you to swipe horizontally through views on a dashboard. You could put one or two vertical stacks in each view and scroll through them that way.

1 Like

This is possible with CSS, you just need to add the style ‘overflow-x: scroll’ to the container which contains the cards. I’m using this for simple sliders, works great and looks like this:

3 Likes

Can you post example code?

Sure, i’m going to post the code for the card in my screenshot as an example. The card is made with “paper button row” and the only important part for the scroll effect is setting “overflow-x: scroll” to the parent container. It works best if you give the single cards/buttons within the container a fixed width.

The last card in the code is positioned sticky and is just there for a simple fade-out effect.

type: custom:paper-buttons-row
styles:
  justify-content: start
  align-items: center
  flex-direction: row
  gap: 8px
  margin: 24px 16px 24px 16px
  padding: 0px 0px
  overflow-x: scroll
buttons:
  - icon: mdi:flash
    layout: icon_state
    name: Strom
    entity: input_boolean.status
    ripple: none
    tap_action:
      action: toggle
      confirmation:
        text: Alles ausschalten?
    state_text:
      'on': Strom
      'off': aus
    state_icons:
      'off': mdi:flash-off
    state_styles:
      'on':
        icon:
          background-color: var(--accent-color)
    styles:
      button:
        display: flex
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: var(--light-card-background)
        background-image: |
          {% if is_state('input_boolean.status', 'on') %}
            linear-gradient(var(--accent-color-background), var(--accent-color-background))
          {% else %}
            none
          {% endif %}
        color: var(--text-color)
      state:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 26px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: var(--background-color)
  - icon: mdi:lightbulb-on
    layout: icon_name
    entity: light.lichtgruppe_alle_lampen
    ripple: none
    name: Licht
    tap_action:
      action: navigate
      navigation_path: '#popup_licht'
    state_icons:
      'off': mdi:lightbulb-off-outline
    state_styles:
      'on':
        icon:
          background-color: var(--accent-light-on)
        button:
          display: flex
    styles:
      button:
        display: none
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: var(--light-card-background)
        background-image: |
          {% if is_state('light.lichtgruppe_alle_lampen', 'on') %}
            linear-gradient(var(--accent-light-on-background), var(--accent-light-on-background))
          {% else %}
            none
          {% endif %}
        color: var(--text-color)
      name:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 26px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: var(--background-color)
  - icon: mdi:thermometer
    entity: sensor.temperatur_durchschnitt
    layout: icon_state
    state:
      postfix: °
    ripple: none
    tap_action:
      action: none
    styles:
      button:
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: rgba(245, 158, 39, 0.1)
        color: var(--text-color)
      state:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 26px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: rgba(245, 158, 39, 0.25)
  - icon: mdi:water
    layout: icon_state
    state:
      postfix: '%'
    entity: sensor.luftfeuchtigkeit_durschnitt
    ripple: none
    tap_action:
      action: none
    state_icons:
      active: mdi:pause-circle
      'off': mdi:meditation
    styles:
      button:
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: rgba(0, 97, 152, 0.15)
        color: var(--text-color)
      state:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 32px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: rgba(0, 97, 152, 0.25)
  - icon: mdi:meditation
    layout: icon_name
    name: Selfcare
    ripple: none
    tap_action:
      action: navigate
      navigation_path: '#popup_selfcare'
    entity: timer.selfcare_reminder
    state_icons:
      active: mdi:pause
      'off': mdi:meditation
    state_styles:
      active:
        icon:
          background-color: var(--accent-color)
    styles:
      button:
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: var(--light-card-background)
        background-image: |
          {% if is_state('timer.selfcare_reminder', 'active') %}
            linear-gradient(var(--accent-color-background), var(--accent-color-background))
          {% else %}
            none
          {% endif %}
        color: var(--text-color)
      name:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 32px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: var(--background-color)
  - icon: mdi:vacuum
    layout: icon_name
    ripple: none
    name: Brudi
    tap_action:
      action: navigate
      navigation_path: '#popup_brudi'
    styles:
      button:
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: var(--light-card-background)
        color: var(--text-color)
      name:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 24px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: var(--background-color)
  - icon: mdi:cog-outline
    layout: icon_name
    name: MenĂź
    ripple: none
    tap_action:
      action: navigate
      navigation_path: /config
    styles:
      button:
        padding: 16px 0px
        min-width: 94px
        max-width: 94px
        border-radius: var(--border-radius)
        background-color: var(--light-card-background)
        color: var(--text-color)
      name:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 24px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: var(--background-color)
  - icon: mdi:pen
    layout: icon_name
    name: Ändern
    ripple: fill
    tap_action:
      action: url
      url_path: /lovelace/0?disable_km=&edit=1
    styles:
      button:
        margin-right: 0px
        padding: 16px 0px
        min-width: 90px
        max-width: 90px
        border-radius: var(--border-radius)
        background-color: var(--light-card-background)
        color: var(--text-color)
      name:
        font-weight: 700
        font-size: 16px
      icon:
        '--mdc-icon-size': 24px
        color: var(--text-color)
        padding: 0px
        margin-bottom: 12px
        width: 60px
        min-width: 60px
        max-width: 60px
        height: 60px
        min-height: 60px
        max-height: 60px
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: var(--background-color)
  - icon: mdi:keyboard-space
    layout: icon
    ripple: none
    tap_action:
      action: none
    styles:
      button:
        pointer-events: none
        position: sticky
        right: '-1px'
        margin-left: '-40px'
        background-color: transparent
        background-image: >-
          linear-gradient(90deg,rgba(255,255,255,0) 0%, var(--popup-background)
          80%)
        color: var(--text-color)
        min-width: 50px
        height: 118px
        border-radius: 0px
        z-index: 0
      icon:
        opacity: 0
1 Like

Thanks, the paper-button-row makes sense. I tested it on a standard horizontal-stack as well as as a horizontal-stack in a stack-in-card and wasn’t successful.

I would get the scroll bar, but the main cards would continue to resize the individual cards.

I do use paper-button-row card so this is helpful!

I am looking to do something like this but vertically with numbers. As a way of controlling my thermostat. Do you think just simply taking your code, stacking the items vertically changing the overflow x to overflow y and restricting the hight of the larent card would give me a vertical scorlling list of numbers?

Yes, this works just fine vertically

Pretty amazing skills here see I. May I ask how you achieved this l we vel? Are you a web designer? Or did you learn this specially?

Yes, I work a lot with css in my job, so it feels easier for me to create such cards with paper button row. I use those horizontal slide rows a lot and improved the code a bit. It now supports features like snap in and conditional fade based on the scroll position:

Updated horizontal slider

(I’m happy to share the yaml if someone is interested)

Would love to see the code for that slider. Honestly the whole dashboard looks AMAZING :heart_eyes:

Thank you! I am going to share two versions of my horizontal sliders. Both feature a dynamic fade effect on the edges that responds to scroll position (this was hard to achieve with css alone but at some point I was dedicated to solve it haha). The sliders also include a snap-to-place functionality for individual slides.

The first slider is positioned below my dashboard header and includes a conditional card that appears when my oven is active, displaying the remaining cooking time. This approach can be adapted to show conditional cards based on any trigger, with the card being hidden via CSS when inactive. Additionally, I’ve implemented a sticky positioning for the first card, which remains fixed in place and provides quick access to Home Assistant settings:

type: custom:paper-buttons-row
extra_styles: |
  div.flex-box:before {
    animation-direction: normal;
    animation-name: reveal;
    animation-duration: 1ms;
    animation-timeline: --scroll-timeline;
    content: "";
    position: absolute !important;
    left: 56px;
    height: 80px;
    width: 80px;
    z-index: 1;
    pointer-events: none;
    background: linear-gradient(to left, transparent 0%,
      var(--background-color) 90%);
    }
    
  div.flex-box:after {
    animation-direction: reverse;
    animation-name: reveal;
    animation-duration: 1ms;
    animation-timeline: --scroll-timeline;
    content: "";
    position: absolute !important;
    pointer-events: none;
    right: 16px;
    height: 80px;
    width: 80px;
    pointer-events: none;
    background: linear-gradient(to right, transparent 0%,
      var(--background-color) 90%);
    }

    @keyframes reveal {
    0% {
      opacity: 0;
    }
    20% {
    opacity: 1;
    }}
styles:
  justify-content: start
  gap: 8px
  margin: 0px 16px 0px 16px
  overflow-x: scroll
  scroll-padding-block-start: 200px
  overscroll-behavior-x: contain
  scroll-snap-type: x mandatory
  scroll-timeline: "--scroll-timeline x"
  border-radius: var(--border-radius) 0px 0px var(--border-radius)
base_config:
  layout: icon
  ripple: none
  styles:
    button:
      flex: 1
      scroll-snap-align: start
      scroll-margin-left: 64px
      border-radius: var(--border-radius)
      padding: 0px
      justify-content: center
      background-color: var(--card-background)
    icon:
      "--mdc-icon-size": 34px
      color: var(--text-color)
      padding: 0px
      min-width: 80px
      min-height: 80px
      border-radius: var(--border-radius)
      display: flex
      justify-content: center
      align-items: center
      background-color: transparent
buttons:
  - icon: mdi:dots-vertical
    tap_action:
      action: navigate
      navigation_path: /config
    styles:
      button:
        position: sticky
        left: 0px
        z-index: 2
        padding: 0px 4px
        margin-right: 8px
        min-width: 40px
        min-height: 80px
        background-color: var(--card-background)
      icon:
        "--mdc-icon-size": 36px
  - icon: mdi:heat-wave
    layout: state_name
    entity: sensor.mandalofen_verbleibende_zeit
    state:
      case: lower
    name: Min
    tap_action:
      action: more-info
    state_styles:
      aus:
        button:
          opacity: 0
          transform: scale(0)
          margin-left: "-88px"
    styles:
      state:
        max-width: 60px
        pointer-events: none
        overflow: hidden
        text-overflow: ellipsis
        font-weight: 700
        font-size: 22px
        color: var(--text-color)
      name:
        max-width: 60px
        overflow: hidden
        text-overflow: ellipsis
        font-weight: 700
        font-size: 16px
        opacity: 0.5
        color: var(--text-color)
      button:
        display: flex
        padding: 0px 0px
        min-width: 72px
        min-height: 72px
        transition: all 0.4s ease-in-out
        border-radius: var(--border-radius)
        font-size: 16px
        border: 4px dashed rgba(207, 44, 12, 0.6)
        background-color: rgba(207, 44, 12, 0.1)
  - icon: mdi:thermometer
    state_icons:
      heating: mdi:thermometer
    entity: climate.wohnzimmer
    state:
      attribute: hvac_action
    tap_action:
      action: navigate
      navigation_path: "#raumklima"
    styles:
      button:
        display: flex
      icon:
        "--mdc-icon-size": 32px
    state_styles:
      heating:
        button:
          background-color: var(--thermometer-background)
        icon:
          color: var(--text-color-active)
  - icon: mdi:microphone-message
    tap_action:
      action: navigate
      navigation_path: "#durchsage"
    styles:
      icon:
        "--mdc-icon-size": 34px
  - icon: mdi:bathtub-outline
    entity: input_boolean.badezeit_toggle
    layout: icon_name
    name: >
      {{ ((today_at(states('input_datetime.badezeit_ende')) -
      now()).total_seconds() / 60) |int }}
    tap_action:
      action: toggle
    state_icons:
      "on": mdi:bathtub
    state_styles:
      "on":
        button:
          background-color: var(--accent-color-dashboard-background)
        icon:
          display: flex
          "--mdc-icon-size": 24px
          color: var(--accent-color-dashboard)
          margin-bottom: 4px
      "off":
        name:
          display: none
    styles:
      button:
        display: flex
        padding: 0px
        min-width: 80px
        min-height: 80px
        transition: all 0.3s ease-in-out
        border-radius: var(--border-radius)
        background-color: var(--card-background)
        color: var(--text-color)
      icon:
        "--mdc-icon-size": 32px
        color: var(--text-color)
        padding: 0px
        min-height: unset
        transition: all 0.3s ease-in-out
        border-radius: var(--border-radius)
        display: flex
        justify-content: center
        align-items: center
        background-color: transparent
      name:
        max-width: 30px
        white-space: nowrap
        overflow: hidden
        text-overflow: ellipsis
        font-weight: 700
        font-size: 18px
        color: var(--text-color)
  - icon: mdi:robot-vacuum
    entity: vacuum.brudi
    name: Brudi
    tap_action:
      action: navigate
      navigation_path: "#popup_brudi"
    state_styles:
      cleaning:
        button:
          background-color: var(--accent-color-dashboard-background)
    styles:
      icon:
        "--mdc-icon-size": 36px
  - icon: mdi:pen
    entity: input_boolean.show_header
    layout: icon
    tap_action:
      action: toggle
    styles:
      icon:
        "--mdc-icon-size": 32px

The second slider is integrated into a bubble card popup that functions as my TV remote:

This is how it looks when scrolled

type: custom:paper-buttons-row
extra_styles: |
  @keyframes reveal-left {
  0%, 98% {
    opacity: 0;
  }
  100% {
  opacity: 1;
  }}

  @keyframes reveal-right {
  0%, 98% {
    opacity: 1;
  }
  100% {
  opacity: 0;
  }}
styles:
  justify-content: start
  gap: 8px
  margin: 0px 16px 0px 16px
  overflow-x: scroll
  overscroll-behavior-x: contain
  scroll-snap-type: x mandatory
  scroll-timeline: "--scroll-timeline x"
base_config:
  entity: media_player.android_tv_wohnzimmer
  state:
    attribute: source
  layout: icon|name
  ripple: fill
  styles:
    icon:
      "--mdc-icon-size": 26px
      opacity: 0.7
      border-radius: 0px
    button:
      scroll-snap-align: start
      padding: 0px 16px
      height: 60px
      gap: 4px
      border-radius: var(--border-radius)
      transition: all 0.4s ease-in-out
      background-color: var(--card-background)
    name:
      font-size: 18px
      font-weight: 700
      white-space: nowrap
      color: var(--text-color)
buttons:
  - layout: icon
    styles:
      button:
        position: absolute
        left: 16px
        z-index: 1
        height: 60px
        border-radius: 0
        background: linear-gradient(to left, transparent 0%,var(--background-color) 90%)
        animation-name: reveal-left
        animation-direction: normal
        animation-duration: 300ms
        animation-timeline: "--scroll-timeline"
        pointer-events: none
      icon:
        opacity: 0
  - name: TV
    icon: mdi:television-shimmer
    image: /local/Icons/apps/waipu-tv.png
    state_styles:
      de.exaring.waipu:
        button:
          background-color: var(--accent-color-dashboard-background)
    tap_action:
      action: call-service
      service: script.tv_wohnzimmer_fernsehen
  - name: Netflix
    icon: mdi:netflix
    state_styles:
      netflix:
        button:
          background-color: var(--accent-color-dashboard-background)
    tap_action:
      action: call-service
      service: media_player.select_source
      target:
        entity_id: media_player.android_tv_wohnzimmer
      service_data:
        source: Netflix
    styles:
      icon:
        "--mdc-icon-size": 22px
        color: "#E50914"
  - name: Prime
    icon: mdi:television
    image: /local/Icons/apps/prime.svg
    state_styles:
      prime video:
        button:
          background-color: var(--accent-color-dashboard-background)
    tap_action:
      action: call-service
      service: media_player.select_source
      target:
        entity_id: media_player.android_tv_wohnzimmer
      service_data:
        source: com.amazon.amazonvideo.livingroom
    styles:
      icon:
        "--mdc-icon-size": 26px
        width: 20px
        height: 20px
        opacity: 1
  - name: Disney
    icon: mdi:television
    image: /local/Icons/apps/disney_plus.svg
    state_styles:
      disney+:
        button:
          background-color: var(--accent-color-dashboard-background)
    tap_action:
      action: call-service
      service: media_player.select_source
      target:
        entity_id: media_player.android_tv_wohnzimmer
      service_data:
        source: com.disney.disneyplus
  - name: WOW
    icon: mdi:television
    image: /local/Icons/apps/wow.svg
    state_styles:
      de.sky.online:
        button:
          background-color: var(--accent-color-dashboard-background)
    tap_action:
      action: call-service
      service: media_player.select_source
      target:
        entity_id: media_player.android_tv_wohnzimmer
      service_data:
        source: de.sky.online
  - name: YouTube
    icon: mdi:youtube
    state_styles:
      com.liskovsoft.smarttubetv.beta:
        button:
          background-color: var(--accent-color-dashboard-background)
    tap_action:
      action: call-service
      service: media_player.select_source
      target:
        entity_id: media_player.android_tv_wohnzimmer
      service_data:
        source: com.liskovsoft.smarttubetv.beta
    styles:
      icon:
        "--mdc-icon-size": 26px
        color: "#CD201F"
  - layout: icon
    styles:
      button:
        position: absolute
        right: 16px
        z-index: 1
        height: 60px
        border-radius: 0
        background: linear-gradient(to right, transparent 0%,var(--background-color) 90%)
        animation-direction: normal
        animation-name: reveal-right
        animation-duration: 300ms
        animation-timeline: "--scroll-timeline"
        pointer-events: none
      icon:
        opacity: 0

The code for both sliders includes various customizations which have to be edited out/replaced with your own, such as css variables, touch actions and conditional styles. I’ve left these in the code to serve as examples (and, admittedly, because I’m too lazy to edit it out).

3 Likes

Would you able to share your full dashboard? Looks amazing!

Thanks :slightly_smiling_face: I’ve thought about sharing my dashboard but there are so many easier/better alternatives (like for example bubble cards) than doing it the way I did. Because I was so annoyed by the constant customization with card mod, I have implemented everything almost exclusively with those super customized paper button rows. I honestly don’t know if that would be useful for others. I think I’ll test it with a post for a single element like the TV card

Hello,
Is it possible to hide that scroll bar/thumb appearing when scrolling? I have not found any info how to do it. Tried changing themes but did not help.
Do you guys also have it or managed to hide it somehow?
Please see the video Imgur: The magic of the Internet

Nice idea. Adding “scrollbar-width: none” to the parent container hides the scrollbar in chrome.

The code is probably slightly different for Mozilla/webkit, so you have to add all of its variations if you want to cover all browsers

1 Like

This is what I was looking for. Thanks!


Nice card, how you did it?