An animated CSS Gauge w/ mask-image and lerp animation

Cardboardshark
3 min read2 days ago

--

Find the code at https://stackblitz.com/edit/vitejs-vite-bwaohtq9?file=src%2FApp.tsx

I recently built a fancy gauge component for a project, and thought some of the techniques might be of interest!

SVG Mask image and Filter:drop-shadow

I wanted the fill layer to have a satisfying glow to achieve that incandescent dashboard effect. The inner layer has a mask-image SVG, and the wrapper has a filter:drop-shadow.

<div style="glow">
<div style="masked_shape"></div>
</div>

.glow {
filter: drop-shadow(0 0 var(--fillGlowDistance) var(--finalColor));
}
.mask {
mask-image: var(--maskImage);
mask-size: 100%;
mask-repeat: no-repeat;
}

Conic gradient background

The conic gradient sans mask-image

Because the gauge isn’t a perfect semi-circle, rotating a filled rectangle wasn’t going to work. I used a conic-gradient to draw a pie graph for the fill layer, and a lerp ( linear interpolation ) function to calculate the desired percentage within the gradient.


// Component
const calculatedCatchChance = Math.round(lerp(0, MAX_GAUGE_DEGREES, catchChance));

// CSS
background: conic-gradient(
from -120deg at 50% 61%,
#ffd12a, // start color
#ffd12a var( - percentage),
transparent var( - percentage)
);

// Math service
function lerp(min: number, max: number, fraction: number) {
return min * (1 - fraction) + max * fraction;
}

This did introduce some additional challenges, as CSS doesn’t currently support background transitions. Thus…

Lerp animation triggered by Mutation Observer

When the component is mounted, a useCallback ref adds a MutationObserver to listen for DOM changes. If it detects the ‘data-percent’ property changed, it starts a recursive animation ticker that calls requestAnimationFrame until the transition is complete.

It’s also wrapped in an AbortController to prevent competing animations.

const abortController = useRef<AbortController>(null);

const observer = new MutationObserver((mutationList) => {
mutationList.forEach(() => {
// cancel any existing animations
if (abortController.current) {
abortController.current.abort();
}
abortController.current = new AbortController();
startAnimationTick(node, abortController.current.signal);
});
});

observer.observe(node, { attributes: true, attributeFilter: ['data-cc'] });
/**
* Create a new async thread to animate the background.
* The parent will trigger the abort signal if a new animation begins, and the animationTick function will cancel out.
*/
async function startAnimationTick(
element: HTMLDivElement,
duration: number,
signal: AbortSignal
) {
// where are we starting from?
const existingDomValue = window
.getComputedStyle(element)
.getPropertyValue(CSS_VARIABLE_NAME);
const initialValue = Number.isNaN(parseFloat(existingDomValue))
? 0
: parseFloat(existingDomValue);

// what's our goal?
const goalValue = element.dataset.cc ? parseFloat(element.dataset.cc) : 0;

requestAnimationFrame((initialTimestamp) => {
animationTick({
element,
initialValue,
goalValue,
initialTimestamp,
duration,
signal,
});
});
}
interface AnimationTickOptions {
element: HTMLDivElement;
initialValue: number;
goalValue: number;
initialTimestamp: number;
duration: number;
signal: AbortSignal;
}
async function animationTick({
element,
initialValue,
goalValue,
initialTimestamp,
duration,
signal,
}: AnimationTickOptions) {
// early exit if parent has killed this thread.
if (signal.aborted) {
return;
}

requestAnimationFrame((currentTimestamp) => {
const percentOfDurationElapsed =
(currentTimestamp - initialTimestamp) / duration;
const currentCSSPropertyValue = lerp(
initialValue,
goalValue,
percentOfDurationElapsed
);

element.style.setProperty(CSS_VARIABLE_NAME, `${currentCSSPropertyValue}%`);

// recursively call another animationTick
if (percentOfDurationElapsed < 1) {
animationTick({
element,
initialValue,
goalValue,
initialTimestamp,
duration,
signal,
});
}
});
}

It was a fun little challenge, so I hope folks find it useful!

--

--

Cardboardshark
Cardboardshark

No responses yet