An animated CSS Gauge w/ mask-image and lerp animation
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
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!