Fluid Level (Animated) Background Card [original]

Please post in English.

Sorry, I’m not used to using this forum, and there’s a lot of information I need to include, so the post might look cluttered.

Anyone use this to display how much salt is left in a tank ?

Great job.

First of all, thank you for this amazing card. The liquid animation looks fantastic.

I am trying to integrate lovelace-fluid-level-background-card into a circular energy consumption button that I already built using custom:button-card.

My goal is simple:

  • I have a circular energy button (grid consumption / solar production).
  • I want the liquid animation to appear strictly inside the circle.
  • The liquid must not render outside the circular boundary.
  • The circular ring, glow, and value text stay on top.

My original implementation (without fluid-level-background-card) works perfectly. The liquid is clipped inside an SVG circle using clipPath.

However, when I try to use fluid-level-background-card, the liquid renders outside the circular shape, even when applying border-radius and overflow clipping via card_mod or mod-card.

Below is my original working button-card implementation (custom SVG liquid inside circle).

type: custom:button-card
entity: sensor.smappee_5010015633_local_load_grid
show_icon: false
show_name: false
show_state: false
tap_action:
  action: more-info

variables:
  MAX: 9000
  UNIT: W
  MIN_PCT_VISUAL: 0.1
  TILT_DEG: -7
  TILT_SKEW: -6
  WARN_W: 3500
  DANGER_W: 7000

styles:
  card:
    - height: 240px
    - padding: 0px
    - border-radius: 22px
    - background: rgba(0,0,0,0)
    - overflow: hidden
    - display: grid
    - place-items: center
  grid:
    - grid-template-areas: '"gauge"'
    - grid-template-columns: 1fr
    - grid-template-rows: 1fr
  custom_fields:
    gauge:
      - grid-area: gauge
      - justify-self: center
      - align-self: center

custom_fields:
  gauge: |
    [[[
      const num = (x) => {
        const n = Number(x);
        return Number.isFinite(n) ? n : 0;
      };

      const clamp = (x, a, b) => Math.max(a, Math.min(x, b));

      // Grid value (never negative)
      const raw0 = entity && entity.state != null ? num(entity.state) : 0;
      const raw  = Math.abs(raw0);

      const max = variables.MAX !== undefined ? num(variables.MAX) : 9000;
      const v   = clamp(raw, 0, max);

      const warn   = variables.WARN_W   !== undefined ? num(variables.WARN_W)   : 3500;
      const danger = variables.DANGER_W !== undefined ? num(variables.DANGER_W) : 7000;

      const level  = (v >= danger)
        ? "danger"
        : (v >= warn)
        ? "warn"
        : "ok";

      // Color palette (green / yellow / red)
      const pal = {
        ok: {
          ringA: "rgba(90,255,190,0.92)",
          ringB: "rgba(0,200,120,0.92)",
          liqTop: "rgba(140,255,210,0.90)",
          liqBot: "rgba(0,220,120,0.94)",
          wave1: "rgba(90,255,190,0.55)",
          wave2: "rgba(0,200,120,0.36)",
          glow:  "rgba(0,230,118,0.55)"
        },
        warn: {
          ringA: "rgba(255,209,102,0.95)",
          ringB: "rgba(255,176,0,0.95)",
          liqTop: "rgba(255,233,160,0.88)",
          liqBot: "rgba(255,176,0,0.94)",
          wave1: "rgba(255,200,90,0.55)",
          wave2: "rgba(255,176,0,0.36)",
          glow:  "rgba(255,193,7,0.60)"
        },
        danger: {
          ringA: "rgba(255,90,110,0.95)",
          ringB: "rgba(255,23,68,0.95)",
          liqTop: "rgba(255,170,185,0.88)",
          liqBot: "rgba(255,23,68,0.94)",
          wave1: "rgba(255,120,150,0.55)",
          wave2: "rgba(255,23,68,0.36)",
          glow:  "rgba(255,23,68,0.62)"
        }
      }[level];

      const displayVal = Math.round(v);

      return `
        <div style="display:flex; align-items:center; justify-content:center; height:100%;">
          <div style="
            font-size: 56px;
            font-weight: 600;
            color: rgba(255,255,255,0.92);
            text-shadow: 0 10px 25px rgba(0,0,0,0.65);
          ">
            ${displayVal} <span style="font-size:18px; opacity:0.8;">W</span>
          </div>
        </div>
      `;
    ]]]

My intention is to use fluid-level-background-card as the liquid engine, but keep it inside a circular energy meter button.

Example that I liked:

https://aarcoraci.github.io/fluid-meter/

Any guidance would be greatly appreciated.

Hi hotsauce, thanks for the kind words and for the detailed write-up!

Currently the card is designed as a rectangular wrapper β€” the fluid animation renders on a full-size canvas and gets
clipped by the card’s border-radius (overflow: hidden). Making it work cleanly inside a circular boundary would
require changes at the canvas/rendering level, so card_mod or CSS-only approaches won’t reliably get you there.

That said, this is a really cool use case! I’d love to explore adding support for circular shapes. Could you open a
feature request on GitHub so we can track it properly?

Issues Β· swingerman/lovelace-fluid-level-background-card Β· GitHub

It’d be great if you could include the reference you linked (the fluid-meter demo) and describe the desired result β€”
that helps a lot when scoping the work.

Anyone adapt this for a Water Softener Salt tank?

Hi there,
I am just starting to use this card and had a lot of issues at first trying to get rid of the border around the card showing a thin line of the background, at the end what worked for me and make the whole thing looks better is to use the "picture-elements" card but the most important part is to have three pictures one will be the whole card background. (just a transparent rectangle 310x200 I created in paint) then the picture of the tank (in mi case is a 4" PVC pipe I am using as chickens water dispenser) just the tank with the transparent window and transparent background but just enough to fit the tank inside, then another transparent rectangle slightly bigger than the window on the tank (this is the one with the fluid wrapper applied ).

here is the code, the first three elements are just badges showing some related data.

I put together the sensor too, let me know if interested in the details about it.

type: picture-elements
image: /local/tanque1back.png # transparent card background is important if you want this to look good on any theme dark or light. 
elements:
  - type: state-badge
    entity: sensor.volume_sensor_liters_decimal
    style:
      color: transparent
      top: 25%
      left: 35%
  - type: state-badge
    entity: binary_sensor.chickens_water_valve_work_state
    style:
      color: transparent
      top: 58%
      left: 35%
  - type: state-badge
    entity: sensor.chickens_water_flow
    style:
      color: transparent
      top: 89%
      left: 35%
  - type: custom:fluid-level-background-card
    entity: sensor.volume_sensor_liters_decimal
    fill_entity: binary_sensor.chickens_water_valve_work_state
    card:
      type: picture
      image: /local/tanquebg.png # transparent rectangle where the actual fluid-level will be on. 
    style:           # you need to play with this numbers to position both images aligned and important to use % so it will work on any screen size.
      top: 50%   
      left: 85%
      width: 5.5%
    background_color:
      - 80
      - 80
      - 80
      - 1
    full_value: 7
    top_margin: 10
  - type: image
    image: /local/tanque1.png # picture of the tank with a transparent window and transparent background
    style:
      top: 50%
      left: 83%
      width: 17%

1 Like

Can you share the code for this?

Solved...

Here's exactly what makes the difference, layer by layer.

1. The clipping problem β€” why previous attempts leaked

The original approach used fluide-level-background-card, which is a separate shadow DOM element. CSS properties like overflow: hidden and border-radius on a parent element cannot clip a child that lives inside its own shadow root β€” that's a hard browser limitation. The liquid always bled outside the circle.

The fix: do everything inside a single SVG, using a native <clipPath>. A SVG clipPath clips at the pixel rendering level, before compositing β€” it's absolute and unconditional. Nothing escapes it.

<clipPath id="clip_xyz">
  <circle cx="150" cy="150" r="118"/>
</clipPath>
<g clip-path="url(#clip_xyz)">
  <!-- everything here is hard-clipped to the circle -->
</g>

2. The wave shape β€” Q-path trick

Real water waves are sinusoidal curves. SVG has no sin() function, so the wave is built with quadratic BΓ©zier curves (the Q command), which approximate a sine wave very well.

The mkWave() function generates a path that spans from -300 to +600 in X β€” much wider than the visible 300px viewport. This matters because the wave translates horizontally during animation; if it were only 300px wide, you'd see the edge appear and disappear. The wide coverage keeps the surface continuous at all times.

M -300 Y  Q -262 Y-amp  -225 Y  Q -187 Y+amp  -150 Y  ... L 600 300 L -300 300 Z

The shape closes downward (L 600 300 L -300 300 Z), forming a filled polygon β€” the liquid body.


3. Two waves, opposite directions, different speeds

A single wave moving in one direction looks mechanical. Two waves moving in opposite directions at different speeds create interference patterns β€” the surface appears to rise, dip, and ripple unpredictably, exactly like real liquid:

/* Wave 1 β€” front, moves left */
@keyframes wA { 0% { translateX(0) } 100% { translateX(-150px) } }

/* Wave 2 β€” back, moves right */
@keyframes wB { 0% { translateX(-150px) } 100% { translateX(0) } }

Wave 2 is slightly offset in Y (+3px) and has a smaller amplitude (Γ—0.9 vs Γ—1.1), giving the illusion of depth β€” a foreground wave and a background wave.


4. Adaptive amplitude

At 0% or 100% fill, a wave with large amplitude would look wrong β€” you'd see the crest poking above the circle top or the trough below empty. The amplitude is calculated as:

const edgeFactor = Math.sin(Math.PI * visual); // β†’ 0 at edges, 1 at 50%
const amp = clamp(14 * edgeFactor, 0, 14);

This is a sine envelope: amplitude is zero when nearly empty or nearly full, and peaks at the midpoint. The wave shrinks naturally as the liquid approaches either extreme.


5. Speed tied to load level

Higher power consumption = faster, more agitated waves:

const spd1 = clamp(4.2 - (pctRaw * 2.4) + jitter, 1.4, 4.6);

At 0% load: ~4.2s per cycle (slow, calm). At 100% load: ~1.8s per cycle (fast, urgent). The jitter adds a tiny random offset so two cards on the same dashboard never animate in perfect sync.


6. Surface detail layers

Three layers sit on top of the waves to simulate the optical properties of water:

  • Shadow β€” a blurred dark stroke just below the surface (feGaussianBlur stdDeviation="3.2"), giving the impression that the liquid has thickness.
  • Highlight β€” a semi-transparent white curve right on the surface, fading in from the sides (linearGradient horizontal), simulating light reflection.
  • Shimmer β€” a thin horizontal rectangle centered on liquidY, with a horizontal gradient peaking at 22% white opacity, imitating the specular gleam you see on moving water.

Together these three layers make the surface look wet and three-dimensional rather than flat and painted.

Code:

type: custom:button-card
entity: sensor.smappee_5010015633_local_load_grid
show_icon: false
show_name: false
show_state: false
tap_action:
action: more-info
variables:
MAX: 12000
UNIT: W
MIN_PCT_VISUAL: 0.1
TILT_DEG: -7
TILT_SKEW: -6
WARN_W: 3500
DANGER_W: 8500
styles:
card:
- height: 240px
- padding: 0px
- border-radius: 22px
- background: rgba(0,0,0,0)
- overflow: hidden
- display: grid
- place-items: center
grid:
- grid-template-areas: ""gauge""
- grid-template-columns: 1fr
- grid-template-rows: 1fr
custom_fields:
gauge:
- grid-area: gauge
- justify-self: center
- align-self: center
custom_fields:
gauge: |
[[[
const num = (x) => { const n = Number(x); return Number.isFinite(n) ? n : 0; };
const clamp = (x,a,b) => Math.max(a, Math.min(x, b));

  const raw0 = entity && entity.state != null ? num(entity.state) : 0;
  const raw  = Math.abs(raw0);

  const max = variables.MAX !== undefined ? num(variables.MAX) : 12000;
  const v   = clamp(raw, 0, max);

  const warn   = variables.WARN_W   !== undefined ? num(variables.WARN_W)   : 3500;
  const danger = variables.DANGER_W !== undefined ? num(variables.DANGER_W) : 8500;
  const level  = (v >= danger) ? "danger" : (v >= warn) ? "warn" : "ok";

  const pal = {
    ok: {
      ringA: "rgba(90,255,190,0.92)",   ringB: "rgba(0,200,120,0.92)",
      liqTop: "rgba(140,255,210,0.90)", liqBot: "rgba(0,220,120,0.94)",
      wave1: "rgba(90,255,190,0.55)",   wave2: "rgba(0,200,120,0.36)"
    },
    warn: {
      ringA: "rgba(255,209,102,0.95)",   ringB: "rgba(255,176,0,0.95)",
      liqTop: "rgba(255,233,160,0.88)", liqBot: "rgba(255,176,0,0.94)",
      wave1: "rgba(255,200,90,0.55)",   wave2: "rgba(255,176,0,0.36)"
    },
    danger: {
      ringA: "rgba(255,90,110,0.95)",   ringB: "rgba(255,23,68,0.95)",
      liqTop: "rgba(255,170,185,0.88)", liqBot: "rgba(255,23,68,0.94)",
      wave1: "rgba(255,120,150,0.55)",  wave2: "rgba(255,23,68,0.36)"
    }
  }[level];

  const pctRaw = max > 0 ? (v / max) : 0;

  const minPct = variables.MIN_PCT_VISUAL !== undefined ? num(variables.MIN_PCT_VISUAL) : 0.10;
  const eased  = Math.pow(clamp(pctRaw, 0, 1), 0.45);

  const MAX_VISUAL = 0.97;
  const visual = Math.max(minPct, Math.min(eased, MAX_VISUAL));

  // ── Liquid geometry ────────────────────────────────────────
  // viewBox 300x300, circle cx=150 cy=150 r=118
  // circle top = 32, circle bottom = 268
  const TOP = 150 - 118, BOT = 150 + 118;
  const liquidY = BOT - visual * (BOT - TOP);

  // Adaptive amplitude β€” tapers at the edges (nearly empty or nearly full)
  const edgeFactor = Math.sin(Math.PI * visual);
  const amp = clamp(14 * edgeFactor, 0, 14);

  // Variable speeds based on load level (original logic with jitter)
  const jitter = (Math.random() - 0.5) * 0.25;
  const spd1 = clamp(4.2 - (pctRaw * 2.4) + jitter, 1.4, 4.6);
  const spd2 = clamp(3.4 - (pctRaw * 2.0) - jitter, 1.1, 3.8);
  const spd3 = clamp(4.8 - (pctRaw * 2.1) + jitter * 0.6, 1.6, 5.2);

  // Unique IDs per entity to avoid conflicts when multiple cards are on the same view
  const uid = entity ? entity.entity_id.replace(/[^a-z0-9]/gi, '_') : 'grid';
  const clipId     = `clip_${uid}`;
  const ringGradId = `ringGrad_${uid}`;
  const liqGradId  = `liqGrad_${uid}`;
  const hlGradId   = `hlGrad_${uid}`;
  const shimId     = `shim_${uid}`;
  const softBlurId = `softBlur_${uid}`;
  const softGlowId = `softGlow_${uid}`;
  const wA         = `wA_${uid}`;
  const wB         = `wB_${uid}`;
  const wC         = `wC_${uid}`;

  // Q-path waves β€” wide coverage; the clipPath keeps them inside the circle
  const y1 = liquidY, y2 = liquidY + 3;
  const a  = amp;

  const mkWave = (y, a, extra) => {
    const segs = [-300, -225, -150, -75, 0, 75, 150, 225, 300, 375, 450, 525, 600];
    let d = `M ${segs[0]} ${y}`;
    for (let i = 0; i < segs.length - 1; i++) {
      const mx  = (segs[i] + segs[i+1]) / 2;
      const cy_ = i % 2 === 0 ? y - a * (extra||1) : y + a * (extra||1);
      d += ` Q ${mx} ${cy_} ${segs[i+1]} ${y}`;
    }
    d += ` L ${segs[segs.length-1]} 300 L ${segs[0]} 300 Z`;
    return d;
  };

  const wave1Path = mkWave(y1, a, 1.1);
  const wave2Path = mkWave(y2, a, 0.9);

  const hlPath = (() => {
    const segs = [0, 75, 150, 225, 300];
    let d = `M ${segs[0]} ${y2 - 2}`;
    for (let i = 0; i < segs.length - 1; i++) {
      const mx  = (segs[i] + segs[i+1]) / 2;
      const cy_ = i % 2 === 0 ? y2 - 2 - a*0.8 : y2 - 2 + a*0.9;
      d += ` Q ${mx} ${cy_} ${segs[i+1]} ${y2 - 2}`;
    }
    return d;
  })();

  const shadowPath = (() => {
    const segs = [0, 75, 150, 225, 300];
    let d = `M ${segs[0]} ${y2 + 5}`;
    for (let i = 0; i < segs.length - 1; i++) {
      const mx  = (segs[i] + segs[i+1]) / 2;
      const cy_ = i % 2 === 0 ? y2 + 5 - a*0.8 : y2 + 5 + a*0.9;
      d += ` Q ${mx} ${cy_} ${segs[i+1]} ${y2 + 5}`;
    }
    return d;
  })();

  const unit       = variables.UNIT !== undefined ? variables.UNIT : "W";
  const S          = 248;
  const displayVal = Math.round(v);

  return `
  <div class="liq ${level}">
    <svg viewBox="0 0 300 300" width="${S}" height="${S}" class="svg">
      <defs>
        <clipPath id="${clipId}">
          <circle cx="150" cy="150" r="118"/>
        </clipPath>

        <linearGradient id="${ringGradId}" x1="0%" y1="0%" x2="100%" y2="100%">
          <stop offset="0%"   stop-color="${pal.ringA}"/>
          <stop offset="100%" stop-color="${pal.ringB}"/>
        </linearGradient>

        <linearGradient id="${liqGradId}" x1="0%" y1="0%" x2="0%" y2="100%">
          <stop offset="0%"   stop-color="${pal.liqTop}"/>
          <stop offset="100%" stop-color="${pal.liqBot}"/>
        </linearGradient>

        <linearGradient id="${hlGradId}" x1="0%" y1="0%" x2="100%" y2="0%">
          <stop offset="0%"   stop-color="rgba(255,255,255,0.00)"/>
          <stop offset="30%"  stop-color="rgba(255,255,255,0.40)"/>
          <stop offset="70%"  stop-color="rgba(255,255,255,0.22)"/>
          <stop offset="100%" stop-color="rgba(255,255,255,0.00)"/>
        </linearGradient>

        <linearGradient id="${shimId}" x1="0%" y1="0%" x2="100%" y2="0%">
          <stop offset="0%"   stop-color="rgba(255,255,255,0.00)"/>
          <stop offset="45%"  stop-color="rgba(255,255,255,0.18)"/>
          <stop offset="55%"  stop-color="rgba(255,255,255,0.22)"/>
          <stop offset="100%" stop-color="rgba(255,255,255,0.00)"/>
        </linearGradient>

        <filter id="${softBlurId}" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur stdDeviation="3.2"/>
        </filter>

        <filter id="${softGlowId}" x="-50%" y="-50%" width="200%" height="200%">
          <feGaussianBlur stdDeviation="1.6" result="b"/>
          <feMerge>
            <feMergeNode in="b"/>
            <feMergeNode in="SourceGraphic"/>
          </feMerge>
        </filter>
      </defs>

      <!-- Dark circle background -->
      <circle cx="150" cy="150" r="125"
        fill="rgba(12,14,18,0.55)"
        stroke="rgba(255,255,255,0.10)"
        stroke-width="2"/>

      <!-- Colored outer ring -->
      <circle cx="150" cy="150" r="125"
        fill="none"
        stroke="url(#${ringGradId})"
        stroke-width="6"
        opacity="0.95"/>

      <!-- Subtle inner ring -->
      <circle cx="150" cy="150" r="118"
        fill="none"
        stroke="rgba(255,255,255,0.12)"
        stroke-width="2"
        opacity="0.90"/>

      <!-- ══ CLIPPED GROUP β€” liquid stays inside the circle ══ -->
      <g clip-path="url(#${clipId})">

        <!-- Dark inner background -->
        <rect x="0" y="0" width="300" height="300" fill="rgba(16,18,22,0.55)"/>

        <!-- Ambient reflection (identical to original) -->
        <ellipse cx="150" cy="132" rx="140" ry="92" fill="rgba(255,255,255,0.04)"/>

        <!-- Solid liquid body below the wave -->
        <rect x="0" y="${liquidY + amp}" width="300" height="${300 - liquidY - amp}"
          fill="url(#${liqGradId})"/>

        <!-- Wave 1 β€” front, moves left -->
        <g style="animation: ${wA} ${spd1}s linear infinite;">
          <path d="${wave1Path}" fill="${pal.wave1}"/>
        </g>

        <!-- Wave 2 β€” back, moves right, opposite phase -->
        <g style="animation: ${wB} ${spd2}s linear infinite;">
          <path d="${wave2Path}" fill="${pal.wave2}"/>
        </g>

        <!-- Surface shadow (depth effect) -->
        <path d="${shadowPath}"
          fill="none"
          stroke="rgba(0,0,0,0.38)" stroke-width="10"
          filter="url(#${softBlurId})" opacity="0.55"/>

        <!-- Surface highlight -->
        <path d="${hlPath}"
          fill="none"
          stroke="url(#${hlGradId})" stroke-width="3"
          stroke-linecap="round"
          filter="url(#${softGlowId})" opacity="0.88"/>

        <!-- Shimmer band -->
        <rect x="0" y="${liquidY - 4}" width="300" height="10"
          fill="url(#${shimId})" opacity="0.65"/>

      </g>
      <!-- ══ END CLIPPED GROUP ══ -->

      <!-- Dark inner border for depth (identical to original) -->
      <circle cx="150" cy="150" r="118"
        fill="none"
        stroke="rgba(0,0,0,0.55)"
        stroke-width="12"
        opacity="0.55"/>
    </svg>

    <div class="txt">
      <div class="txt-inner">
        <span class="num">${displayVal}</span>
        <span class="unit">${unit}</span>
      </div>
    </div>
  </div>

  <style>
    @keyframes ${wA} {
      0%   { transform: translateX(0); }
      100% { transform: translateX(-150px); }
    }
    @keyframes ${wB} {
      0%   { transform: translateX(-150px); }
      100% { transform: translateX(0); }
    }

    .liq { position: relative; width: ${S}px; height: ${S}px; }
    .svg { display: block; }

    .txt {
      position: absolute; inset: 0;
      display: flex; align-items: center; justify-content: center;
      pointer-events: none;
    }
    .txt-inner {
      display: flex; align-items: baseline; justify-content: center;
      gap: 8px;
      color: rgba(255,255,255,0.92);
      font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
      text-shadow: 0 10px 25px rgba(0,0,0,0.65);
      max-width: 92%;
      white-space: nowrap;
    }
    .num {
      font-weight: 650;
      letter-spacing: -1px;
      font-size: clamp(40px, 9vw, 64px);
      line-height: 1;
    }
    .unit {
      opacity: .85;
      font-size: clamp(12px, 3.6vw, 18px);
      transform: translateY(0.55em);
    }
  </style>
  `;
]]]

card_mod:
style: >
ha-card {
background: rgba(0,0,0,0) !important;
border-radius: 22px !important;
overflow: hidden !important;
box-shadow: 0 10px 26px rgba(0,0,0,0.45) !important;
}

{% set v = states('sensor.smappee_5010015633_local_load_grid') | float(0) | abs %}

{% if v >= 8500 %}
  ha-card{
    border: 1px solid rgba(255, 23, 68, 0.28) !important;
    box-shadow:
      0 10px 26px rgba(0,0,0,0.45),
      0 0 14px rgba(255, 23, 68, 0.20),
      0 0 30px rgba(255, 23, 68, 0.14),
      0 0 56px rgba(255, 23, 68, 0.08) !important;
  }
{% elif v >= 3500 %}
  ha-card{
    border: 1px solid rgba(255, 193, 7, 0.26) !important;
    box-shadow:
      0 10px 26px rgba(0,0,0,0.45),
      0 0 14px rgba(255, 193, 7, 0.18),
      0 0 30px rgba(255, 193, 7, 0.12),
      0 0 56px rgba(255, 193, 7, 0.07) !important;
  }
{% else %}
  ha-card{
    border: 1px solid rgba(0, 230, 118, 0.22) !important;
    box-shadow:
      0 10px 26px rgba(0,0,0,0.45),
      0 0 14px rgba(0, 230, 118, 0.14),
      0 0 30px rgba(0, 230, 118, 0.10),
      0 0 56px rgba(0, 230, 118, 0.06) !important;
  }
{% endif %}

Hey all :waving_hand: β€” big thanks for this thread. The masking use-cases and the realistic-wave experiments you shared here fed straight into the card, and there's now a beta you can try.

Realistic waves β€” with a performance choice
The editor's new Waves tab has three styles:

  • Classic β€” the original look.
  • Realistic β€” liquid with a depth gradient and a soft surface sheen, and waves that flatten out near empty/full. It redraws every frame on the canvas, so it shines with a handful of cards.
  • Realistic (performance) β€” the same look, but drawn to a bitmap once and scrolled with a GPU-composited CSS transform instead of repainting each frame. It holds 60 fps even with lots of cards on a board. The trade-off: the surface scrolls rather than truly morphing up and down, so it uses a subtly detuned second layer to fake that "breathing."

That's the crux of the performance comparison behind the approaches discussed here: per-frame canvas redraw gives the truest motion, but its cost grows with card count Γ— canvas size Γ— frame rate; render-once + CSS-transform scroll is nearly free per frame because the browser just composites a pre-rendered strip on the GPU. So the beta ships both β€” pick fidelity or scale.

Image masking β€” built in
You can now clip the fluid to a shape without card-mod. In the new Shape tab, pick a built-in silhouette (bottle, battery, droplet, tank) or choose Custom to upload or link your own image, plus a Mask Size control (fit / fill / stretch). Under the hood it's the same native CSS mask the card-mod workaround relied on β€” zero per-frame cost β€” just exposed in the UI with presets and an uploader.

Also new: Level / Background opacity sliders (severity colours follow the level opacity), and the Appearance tab is split into Colors / Waves / Shape so the options are easier to find.

It's a beta, so it only appears if you enable beta releases for the card in HACS. Please kick the tyres β€” the masking in particular is likely to be fine-tuned based on what you find. Feedback and issues very welcome :folded_hands:

2 Likes

This is how my card looks. I can't seem to display the water level percentage. Any ideas?

type: custom:fluid-level-background-card
card:
  type: picture
  image:
    media_content_id: media-source://image_upload/ccb677a93a618451d608aeb970541d0c
    media_content_type: image/png
    metadata:
      title: tanknew.png
      thumbnail: /api/image/serve/ccb677a93a618451d608aeb970541d0c/256x256
      media_class: image
      navigateIds:
        - {}
        - media_content_type: app
          media_content_id: media-source://image_upload
entity: sensor.tank_water_level_esp32_c3_12_tank_water_level_percentage
fill_entity: binary_sensor.tank_water_flow_status
full_value: 100
show_value: true
state_text: true
level_color: rgba(0,130,255,1)
background_color: rgba(0,0,0,0)
random_start: true
top_margin: 30
wave_height: 30
wave_speed: 50
wave_style: realistic

@minion1

type: custom:fluid-level-background-card
entity: sensor.tank_water_level_esp32_c3_12_tank_water_level_percentage
fill_entity: binary_sensor.tank_water_flow_status
full_value: 100
level_color: rgba(0,130,255,1)
background_color: rgba(0,0,0,0)
random_start: true
top_margin: 15
card:
  type: picture-elements
  image:
    media_content_id: media-source://image_upload/ccb677a93a618451d608aeb970541d0c
    media_content_type: image/png
  elements:
    - type: state-label
      entity: sensor.tank_water_level_esp32_c3_12_tank_water_level_percentage
      style:
        top: 55%
        left: 50%
        transform: translate(-50%, -50%)
        color: white
        font-size: 30px
        font-weight: bold
        text-shadow: 0 1px 4px rgba(0,0,0,0.7)

What was changed and why:
Removed show_value, state_text, wave_height, wave_speed, wave_style. These keys don’t exist in the custom:fluid-level-background-card schema. The card only supports entity, fill_entity, full_value, level_color, background_color, and severity, (github) plus top_margin (capped at 0–20%) and random_start. (GitHub) Any other key is silently ignored β€” no error, it just does nothing. That’s the actual reason the percentage never showed up: there was no valid option to draw text in the first place.
Changed the inner card: from type: picture to type: picture-elements. A plain Picture Card only renders the image β€” it has no mechanism to display an entity’s state. picture-elements lets you place freely-positioned overlay elements on top of the image, which is what’s needed here.
Added a state-label element. This is the piece that actually prints the sensor’s value (the water level percentage) as text, positioned at top: 55%, left: 50% β€” roughly centered on the tank body in the photo. The style block controls color, size, weight, and a text-shadow for readability against the background.
Lowered top_margin from 30 to 15. The card’s docs cap this at 0–20%; going over that range can clip the wave animation at 100% fill.
Kept the image: block exactly as it was (same media_content_id reference), since the tank photo was already rendering correctly β€” that part wasn’t broken.
You’ll likely need to nudge the top/left values in the visual editor to line the label up exactly with the tank’s body in your specific photo.

Thanks @hotsauce β€” the picture-elements + state-label approach is exactly right. The card deliberately draws no text of its own, so overlaying a state-label is the way to do it.

One correction though, so @minion1 doesn’t lose features: wave_height and wave_speed are supported (since v0.2.0), and wave_style is supported in 0.3.0-beta1 β€” that’s the realistic-waves work I posted about a few replies up. All three can stay in the config. Only show_value and state_text are made up; those two are what silently did nothing.

I think you may have been looking at the original iantrich repo β€” this thread is titled β€œ[original]”, but the maintained fork is GitHub - swingerman/lovelace-fluid-level-background-card: This card wraps any other cards and renders a fluid background behind them. Β· GitHub and its README documents every supported option.

@minion1 β€” your top_margin: 30 is above the 0-20% the editor allows, so dropping it to 15 as suggested is correct. And since you’re on the beta: the new mask_image / mask_size options may suit your tank better than a transparent-window PNG β€” you can mask the fluid to the tank shape directly instead of layering images.

Separately, β€œsilently ignored, no error” is fair criticism. I’ll make the card warn on unrecognised config keys so this is visible instead of mysterious.