Smooth Dot Indicators with Embla Carousel and CSS color-mix()
The interview gallery on ibrewmyown.coffee uses image carousels with pagination dots. Standard pagination dots using Embla Carousel toggle between active and inactive, but I wanted them to respond to scroll progress: as you drag, the current indicator fades out while the next one fades in.
Implementation
Embla Carousel exposes scroll progress, making this possible:
const onScroll = (): void => {
const snapList = emblaApi.scrollSnapList();
const progress = emblaApi.scrollProgress();
if (snapList.length < 2) return;
let lower = snapList.length - 2;
for (let i = 0; i < snapList.length - 1; i++) {
if (progress >= snapList[i] && progress <= snapList[i + 1]) {
lower = i;
break;
}
}
const upper = lower + 1;
const range = snapList[upper] - snapList[lower];
const t = range === 0 ? 0 : (progress - snapList[lower]) / range;
dotNodes.forEach((_, i) => {
if (i === lower) setDotProgress(i, 1 - t);
else if (i === upper) setDotProgress(i, t);
else setDotProgress(i, 0);
});
};
As t interpolates from 0 to 1, a CSS custom property --dot-progress is set for each dot. This single value drives two animations: width expansion and color interpolation.
Width and Color
The dot width expands as you approach it:
width: calc(var(--spacing) * 2 + var(--spacing) * 3 * var(--dot-progress, 0));
And the color blends between two states using color-mix():
background-color: color-mix(
in srgb,
var(--color-neutral-600) calc(var(--dot-progress, 0) * 100%),
var(--color-neutral-300)
);
This approach preserves contrast, keeping the active dot darkest and the inactive lightest. The browser handles both interpolations without additional JS overhead.
Hat tip to Derek Briggs for the color fade idea: “What if the current active indicator faded out the primary color during the drag and the next active faded in so that the most active item had the most contrast always?”