A Smooth Precipitation Radar: Leaflet, Frame Crossfade, and Lazy Init
The radar is the most "live" part of the app — and the heaviest on performance and privacy. How to animate it smoothly, load it only when seen, and handle third-party tiles.

Map and Images: Leaflet as the Foundation
A precipitation radar is undoubtedly the most engaging and dynamic element of modern weather applications. When designing the entire Weather InTouch project, I knew the radar would be the centerpiece of the user interface. At the same time, it is the most technically demanding component of the entire platform. Implementing a precipitation radar in a Leaflet-based web app means balancing several conflicting requirements: extreme performance demands when rendering dozens of images over the base map, visual flickering when switching frames in a time loop, and last but not least, protecting user privacy, since map tiles are typically downloaded from third-party servers.
The foundation of the entire solution is the Leaflet library. Unlike heavyweight and often paid platforms like Google Maps or Mapbox GL JS, Leaflet offers excellent performance on mobile devices, a minimal JavaScript bundle size, and complete freedom in choosing map providers.
The radar in our application does not work with forecasts or model data. It displays exclusively recent history—meaning the latest observed precipitation images captured by weather radars in a time loop ending in the present. This data is most commonly provided as tiles or as static images with precisely defined geographic corner coordinates (image overlays).
A simple configuration is sufficient for basic map initialization and preparing for radar images:
const map = L.map('radar-map', {
center: [50.08, 14.43],
zoom: 6,
zoomControl: false,
attributionControl: true
});
const baseLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
});
On top of this base layer, a set of images rotates to represent specific points in time from the recent past, typically in five- or ten-minute intervals.
Crossfading Instead of Flickering
If you try to implement radar image playback by simply toggling the visibility of individual layers in an array, you will run into an unpleasant visual artifact. The browser cannot render the newly displayed layer instantly, which manifests as constant white or transparent flickering between animation steps. On mobile devices with poorer connections or weaker processors, what should be a smooth animation turns into a choppy slideshow that feels cheap.
The solution is a crossfade technique—a smooth transition achieved by changing the opacity between frames. Instead of hiding the previous frame instantly, its opacity is gradually decreased while the new frame's opacity is linearly increased.
With this approach, we keep several layers loaded in memory simultaneously. Leaflet allows setting the opacity for each layer using the setOpacity() method. Preloading is also key to success. Before the animation transitions to the next frame, we must ensure that the image or tile is already fully downloaded into the browser cache.
function crossfade(layerOut, layerIn, duration = 200) {
let start = null;
function step(timestamp) {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / duration, 1);
layerOut.setOpacity(1 - progress);
layerIn.setOpacity(progress);
if (progress < 1) {
requestAnimationFrame(step);
} else {
layerOut.setOpacity(0);
}
}
requestAnimationFrame(step);
}
Using requestAnimationFrame guarantees that transitions are perfectly smooth and synchronized with the refresh rate of the monitor or phone display. The result is a velvety smooth movement of precipitation fields without any flickering.
Playback: Play, Pause, Slider
For the radar to be a truly useful tool, users must have full control over the time loop. The basic control interface consists of three main elements: a button to start and pause playback (play/pause), a timeline slider, and buttons for stepping forward and backward.
The player state is managed by a simple state machine. When the user clicks the play button, a timer starts, advancing the active frame index at regular intervals (e.g., every 800 milliseconds). If the user starts manually dragging the slider, playback is immediately paused to prevent conflicts between automatic progression and manual time selection.
When implementing the timeline slider (often built using a standard HTML5 <input type="range"> element), you need to handle events so that changes take effect immediately without overloading the main rendering thread. Each frame corresponds to a specific observation time, which is dynamically displayed in a text label next to the controls. The step buttons then simply increment or decrement the index of the loaded layers array and immediately trigger a smooth transition to the adjacent frame.
Lazy-Init: The Radar Isn't Built Until It's Visible
The precipitation radar is the most demanding part of the website in terms of transferred data and computing power. Each animation step represents a separate image or set of tiles that the browser must download. If the radar were initialized immediately upon the first page load, users who don't even scroll down to it on the homepage would needlessly download megabytes of data. This would negatively impact Core Web Vitals metrics, especially Cumulative Layout Shift (CLS) and Largest Contentful Paint (LCP).
Therefore, the entire component is designed with lazy initialization (lazy-init) in mind using the IntersectionObserver API.
Until the radar container enters the user's viewport, the map is not created at all, and no base tiles or radar images are downloaded. In place of the radar, only a lightweight placeholder with matching dimensions is displayed to prevent layout shifts.
const radarContainer = document.getElementById('radar-container');
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
initializeRadar();
observer.unobserve(entry.target);
}
});
}, {
rootMargin: '200px'
});
observer.observe(radarContainer);
By setting the rootMargin parameter to 200px, we ensure that the map starts preparing in the background just before the user scrolls to it. For the user, the transition is completely seamless—by the time they reach the radar, the map is already ready for interaction.
Action-Based Consent Instead of Another Banner
Nowadays, user privacy is an absolute priority. However, loading map tiles from third parties means that the user's browser sends requests to the servers of these services, thereby sharing the IP address and other network identifiers. Under GDPR, this transfer should not occur without the user's prior active consent.
Instead of annoying users with another pop-up cookie banner that would block the entire application, we use the concept of 'consent-by-action'.
When a user scrolls to the radar, the map does not load automatically. Instead, an elegant static overlay is displayed, informing them that viewing the interactive map requires downloading data from an external provider. This overlay includes a clear activation button.
Only by clicking this button does the user express active consent to load the map assets. This choice is then saved to the browser's local storage (localStorage), so on the next visit, the radar is displayed automatically without further clicks. This approach significantly improves the user experience while strictly respecting personal data protection.
Legend: From dBZ to mm/h
To make precipitation radar data understandable for the average user, it is not enough to just present them with colorful blobs on a map. Radar measurements are standardly performed in units of radar reflectivity, denoted as dBZ (decibels of reflectivity). For the average person, however, a value of '35 dBZ' is completely meaningless. The user needs to know whether there will be a light drizzle or if a heavy downpour is approaching.
Therefore, a legend is implemented in the application that converts these technical values into understandable millimeters per hour (mm/h). The standard meteorological Marshall–Palmer relation is used for this conversion:
Z = a · R^b
where Z represents radar reflectivity, R is the precipitation intensity in mm/h, and the coefficients a and b are empirically determined constants. For typical rainfall in temperate zones, the values a = 200 and b = 1.6 are most commonly used.
Thanks to this conversion, we can display real values on the legend's color scale:
- Light blue (approx. 15–20 dBZ) corresponds to light drizzle (up to 0.5 mm/h).
- Green to yellow (30–40 dBZ) indicates moderate to heavy rain (2–8 mm/h).
- Red and purple (above 50 dBZ) warn of extreme precipitation and hail (tens of mm/h).
The map interface also includes a location pin (teardrop pin). This is a shared SVG component that is tightly integrated with the location picker from the application's main search bar. This pin dynamically shows the user's currently selected location directly on the radar map. Users can see at a glance how close the precipitation band is to their home, which dramatically increases the practical value of the entire visualization.
If you are interested in how the platform is put together from an overall architectural standpoint, where I combine NestJS, MongoDB, and Redis to ensure fast response times, I recommend taking a look at my previous article. Detailed information about the features, design, and overall direction of this project can then be found directly on the page describing the entire Weather InTouch project.

