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');
]]]


