Animated Washer and Dryer Status Card

I am using Home Assistant Yellow with a CM4:

  • Core: 2026.6.3
  • Supervisor: 2026.06.1
  • Operating System: 17.3
  • Frontend: 20260527.6
  • HACS: button-card: v.7.0.1
  • Samsung SmartThings Integration (for select.dryer and select.washer)

I used Google Gemini AI from the web interface to construct this custom UI card to show the washing machine drum rotating and colored yellow when running and show the dryer drum rotating and colored green when running.

The end result should look like this when both sit idle:

Dryer running:

Washer running:

The yaml code to copy and paste into your Picture Elements card:

# Main container using absolute positioning to layer multiple elements on top of an image background
type: picture-elements
# Transparent 100x65 SVG image serves as a responsive canvas layout ratio
image: >-
  data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgNjUiPjwvc3ZnPg==
# Global dashboard card styling overrides
card_mod:
  style: |
    ha-card {
      background: transparent !important; /* Hides the default card background box */
      border: none !important;             /* Removes the standard card borders */
      box-shadow: none !important;         /* Removes card drop shadows */
      overflow: hidden;                    /* Prevents child elements from spilling out */
    }
elements:
  # =========================================================================
  # DRYER DISPLAY SECTION (Positioned on the Left Side at X: 25%)
  # =========================================================================

  # Static background icon showing the standard system icon state for the dryer
  - type: state-icon
    entity: select.dryer
    icon: mdi:tumble-dryer
    style:
      top: 32.0%
      left: 25%
      transform: translate(-50%, -50%) scale(3.5) # Blows up the icon size to 3.5x

  # Custom mask layer to visually block out portions of the spinning animation below
  - type: custom:button-card
    entity: select.dryer
    show_name: false
    show_state: false
    show_icon: false
    style:
      top: 34.5%
      left: 25%
      transform: translate(-50%, -50%) scale(3.5)
      pointer-events: none                       # Allows user clicks to pass straight through
      width: 26px
      height: 26px
    state:
      # If the dryer is active, render a solid black circle mask inside the icon rim
      - operator: template
        value: >-
          [[[ return !['off', 'idle', 'unknown', 'stopped',
          'stop'].includes(states['select.dryer'].state.toLowerCase()) ]]]
        styles:
          card:
            - background-color: "#000000"
            - border-radius: 50%
            - clip-path: circle(21% at 50% 50%)
    styles:
      card:
        - background-color: transparent
        - border: none
        - box-shadow: none
        - width: 26px
        - height: 26px

  # Animated layer that spins the inner drum icon when the dryer runs
  - type: custom:button-card
    entity: select.dryer
    show_name: false
    show_state: false
    icon: mdi:tumble-dryer
    size: 100%
    style:
      top: 32%
      left: 25%
      transform: translate(-50%, -50%) scale(3.5)
      pointer-events: none
      width: 24px
      height: 24px
    state:
      # Evaluates if dryer state is actively running (not off, idle, or stopped)
      - operator: template
        value: >-
          [[[ return !['off', 'idle', 'unknown', 'stopped',
          'stop'].includes(states['select.dryer'].state.toLowerCase()) ]]]
        styles:
          icon:
            - color: "#ffcc00"                    # Colors the active drum yellow
          card:
            - clip-path: circle(22% at 50% 58%)   # Isolates just the inner spin ring
            - animation: dryer_drum_spin 2.5s linear infinite # Triggers CSS rotation loop
            - transform-origin: 50% 58%           # Sets rotation point to perfectly center the drum
    styles:
      card:
        - background-color: transparent
        - border: none
        - box-shadow: none
        - width: 24px
        - height: 24px
      icon:
        - color: transparent                      # Completely hides the icon if the dryer is idle
    extra_styles: |
      @keyframes dryer_drum_spin {                # Defines the 360-degree rotational spin behavior
        from { transform: rotate(0deg); }
        to { transform: rotate(360deg); }
      }

  # Conditional overlay showing two yellow pause bars over the dryer if it is paused
  - type: conditional
    conditions:
      - entity: select.dryer
        state: paused
    elements:
      - type: custom:button-card
        show_name: false
        show_state: false
        show_icon: false
        style:
          top: 34.5%
          left: 25%
          transform: translate(-50%, -50%) scale(3.5)
          pointer-events: none
          width: 24px
          height: 24px
        styles:
          card:
            # Renders a dual-stripe background pattern representing a standard pause symbol
            - background: >-
                linear-gradient(90deg, #ffcc00 0%, #ffcc00 25%, transparent 25%,
                transparent 75%, #ffcc00 75%, #ffcc00 100%)
            - clip-path: inset(22% 33% 22% 33%)  # Shapes the stripes into crisp rectangular blocks
            - border: none
            - box-shadow: none

  # Text label showing the current state text (e.g., "Drying", "Idle") underneath the icon
  - type: state-label
    entity: select.dryer
    style:
      top: 76%
      left: 25%
      transform: translate(-50%, -50%)
      font-size: 14px

  # Dynamic countdown timer tracking how much cycle time remains for the dryer
  - type: custom:button-card
    entity: sensor.dryer_completion_time
    show_name: false
    show_icon: false
    style:
      top: 86%
      left: 25%
      transform: translate(-50%, -50%)
      width: 100px
    state:
      # Evaluation to hide this countdown completely if the machine is off, idle, or already finished
      - operator: template
        value: |-
          [[[ 
            if (!entity || !entity.state || ['unavailable', 'unknown', '0', ''].includes(entity.state)) return true;
            if (['stopped', 'stop', 'off', 'idle'].includes(states['select.dryer'].state.toLowerCase())) return true;
            return Date.now() >= Date.parse(entity.state);
          ]]]
        styles:
          card:
            - display: none !important;           # Hides the tracking window text field completely
    styles:
      card:
        - background: transparent
        - border: none
        - box-shadow: none
      label:
        - font-size: 14px
        - font-weight: 400
        - color: var(--secondary-text-color)
        - white-space: nowrap
    show_label: true
    # JavaScript calculation computing current real time versus the projected end timestamp string
    label: |-
      [[[
        if (!entity || !entity.state || ['unavailable', 'unknown'].includes(entity.state)) return '';
        var diff = Math.max(0, Date.parse(entity.state) - Date.now());
        var hours = Math.floor(diff / 3600000);
        var minutes = Math.floor((diff % 3600000) / 60000);
        if (hours > 0) return 'In ' + hours + (hours == 1 ? ' hour' : ' hours');
        return 'In ' + minutes + (minutes == 1 ? ' minute' : ' minutes');
      ]]]


  # =========================================================================
  # WASHER DISPLAY SECTION (Positioned on the Right Side at X: 75%)
  # =========================================================================

  # Static background icon showing the standard washing machine frame layout
  - type: state-icon
    entity: select.washer
    icon: mdi:washing-machine
    style:
      top: 32%
      left: 75%
      transform: translate(-50%, -50%) scale(3.5)

  # Custom animated layer to simulate spinning wash cycles with a dual-colored background split mask
  - type: custom:button-card
    entity: select.washer
    show_name: false
    show_state: false
    show_icon: false
    style:
      top: 32%
      left: 75%
      transform: translate(-50%, -50%) scale(3.5)
      pointer-events: none
      width: 24px
      height: 24px
    state:
      # Evaluates if the washer is currently running a cycle
      - operator: template
        value: >-
          [[[ return ['run',
          'running'].includes(states['select.washer'].state.toLowerCase()) ]]]
        styles:
          card:
            # Splice effect representing half water/clothing lines inside the tumbler tub area
            - background: "linear-gradient(135deg, #000000 50%, #00cc66 50%)"
            - border-radius: 50%
            - clip-path: circle(20% at 50% 60%)   # Limits rotation visibility bounds to the window port
            - animation: washer_drum_spin 2s linear infinite # Runs continuous rotation loops
            - transform-origin: 50% 60%           # Axis anchor matching the center of the visual tub
    styles:
      card:
        - background-color: transparent
        - border: none
        - box-shadow: none
        - width: 24px
        - height: 24px
    extra_styles: |
      @keyframes washer_drum_spin {                # Shared rotational template loop definition
        from { transform: rotate(0deg); }
        to { transform: rotate(360deg); }
      }

  # Conditional overlay displaying two green pause bars over the washer icon if it gets paused
  - type: conditional
    conditions:
      - entity: select.washer
        state: paused
    elements:
      - type: custom:button-card
        show_name: false
        show_state: false
        show_icon: false
        style:
          top: 34.5%
          left: 75%
          transform: translate(-50%, -50%) scale(3.5)
          pointer-events: none
          width: 24px
          height: 24px
        styles:
          card:
            # Renders the distinct green dual pause lines matching the system running accent color
            - background: >-
                linear-gradient(90deg, #00cc66 0%, #00cc66 25%, transparent 25%,
                transparent 75%, #00cc66 75%, #00cc66 100%)
            - clip-path: inset(22% 33% 22% 33%)
            - border: none
            - box-shadow: none

  # Text label displaying operational text status for the washer (e.g., "Rinsing", "Stopped")
  - type: state-label
    entity: select.washer
    style:
      top: 76%
      left: 75%
      transform: translate(-50%, -50%)
      font-size: 14px

  # Dynamic countdown timer tracking how much cycle time remains for the washer
  - type: custom:button-card
    entity: sensor.washer_completion_time
    show_name: false
    show_icon: false
    style:
      top: 86%
      left: 75%
      transform: translate(-50%, -50%)
      width: 100px
    state:
  # Logic condition checking whether to hide the washer countdown string if idle or empty
    - operator: template
      value: |-
        [[[
          if (!entity || !entity.state || ['unavailable', 'unknown', '0', ''].includes(entity.state)) return true;
          if (['stopped', 'stop', 'off', 'idle'].includes(states['select.washer'].state.toLowerCase())) return true;
          return Date.now() >= Date.parse(entity.state);
        ]]]
      styles:
        card:
          - display: none !important;
    styles:
      card:
        - background: transparent
        - border: none
        - box-shadow: none
      label:
        - font-size: 14px
        - font-weight: 400
        - color: var(--secondary-text-color)
        - white-space: nowrap
    show_label: true
    # Identical parsing calculations resolving operational completion intervals into legible English intervals
    label: |-
      [[[
        if (!entity || !entity.state || ['unavailable', 'unknown'].includes(entity.state)) return '';
        var diff = Math.max(0, Date.parse(entity.state) - Date.now());
        var hours = Math.floor(diff / 3600000);
        var minutes = Math.floor((diff % 3600000) / 60000);
        if (hours > 0) return 'In ' + hours + (hours == 1 ? ' hour' : ' hours');
        return 'In ' + minutes + (minutes == 1 ? ' minute' : ' minutes');
      ]]]

1 Like