Maplair 4.0 — a fork of Leaflet

11 min read Original article ↗

← Back to the list of blog posts

Posted on 19 October 2026 by Simon Legner

Today we’re releasing Maplair 4.0.0, the first stable release of Maplair: a fork of Leaflet, the open-source JavaScript library for interactive maps that Volodymyr Agafonkin created in 2010. It brings many features which Leaflet apps used plugins or workarounds for — map rotation, fullscreen, search, heatmaps, a minimap, legends and more — all of them dropped by bundlers unless you use them.

Maplair builds on the work that went into Leaflet 2.0 — the move to ES modules, standard JavaScript classes and Pointer Events — and continues it: some changes that were proposed for Leaflet, but didn’t land there, are part of Maplair. Many thanks to Volodymyr Agafonkin and to the hundreds of Leaflet contributors, whose work Maplair is based on.

Why 4.0?

Maplair’s first version is 4.0, so that it can’t be confused with Leaflet 2.x or 3.x. If you’re coming from Leaflet 1.9.4, read on for everything that changed in between; if you already use the Leaflet 2.0 alpha, most of it will look familiar.

What’s new

  • Translations of the built-in texts (zoom buttons, popups, scale, attribution …) with the new I18n API. German, Spanish, French, Czech, Slovak, Hungarian, Slovenian, Italian and Romansh are included; help translate on Transifex.

      import {I18n} from 'maplair';
      import de from 'maplair/i18n/de.json' with {type: 'json'};
    
      I18n.registerLocale('de', de);
      I18n.setLocale('de');
    
  • Better performance with many markers: the new map option markerCulling takes markers far outside of the view off the page, and the new marker option shadow: false drops the marker shadows. Popups open with one layout pass instead of two, adding many layers no longer slows down quadratically, and unchanged positions aren’t written to the page again.
  • Tree-shaking: bundlers include only what you use — e.g. a map with just a tile layer is about 34 KB of gzipped JS, about half the size of all of Maplair (63 KB).
  • A new look: a maple leaf logo and marker 🍁.
  • Standard JavaScript classes: classes use constructor() like any other JavaScript class, and new Point(point) creates a copy like any other constructor.
  • Type declarations and a new API reference: the doc comments are JSDoc with TypeScript types now. The package ships type declarations (dist/types/) for Maplair and its optional modules, and the API reference is generated from them, with inherited options, events and methods.

New controls

  • FullscreenControl (map option fullscreenControl) with toggleFullscreen(), isFullscreen() and the events enterfullscreen, exitfullscreen and fullscreenchange. Without the Fullscreen API, e.g. on iPhones, the map fills the window instead.
  • SearchControl: a search field with results to pick with the pointer or the keyboard, finding coordinates and geo URIs by itself (with the new LatLng.parse()). Bring your own geocoder function, or use SearchControl.nominatim() with your own Nominatim server — or with the public one, if your use complies with the Nominatim usage policy, which you confirm with acceptUsagePolicy: true:

      new SearchControl({
          geocoder: SearchControl.nominatim({acceptUsagePolicy: true})
      }).addTo(map);
    
  • ButtonControl and ButtonBarControl for buttons of your own, styled like the zoom buttons, with states, disabling and access keys; TextControl for a title or a status line.
  • MiniMapControl: an overview map showing the view of the map, to drag or click.
  • SideBySideControl: a draggable divider to compare two layers.
  • LegendControl and Classification: classify features by value or by range (like the categorized and graduated renderers of QGIS), style a GeoJSON layer with it, and show the classes in a legend, optionally with the feature count:

      const temperature = new Classification([
          {upper: 0, symbol: {fillColor: '#5bc0de'}, legend: '< 0 °C'},
          {lower: 0, upper: 10, symbol: {fillColor: '#33ee33'}, legend: '0–10 °C'},
          {lower: 10, symbol: {fillColor: '#f0a030'}, legend: '≥ 10 °C'}
      ], {name: 'Air temperature', value: 'temperature'});
    
      new GeoJSON(stations, {
          pointToLayer: (feature, latlng) => new CircleMarker(latlng),
          style: temperature.style
      }).addTo(map);
    
  • ContextMenu for menus on right click or long tap, on the map or on layers.
  • The new control option order sorts the controls of a corner.

Map and interaction

  • Rotation: the map option bearing with setBearing(), rotating with two fingers (pinchRotate) and a compass (RotateControl); Marker options rotationAngle and rotationOrigin, e.g. for the heading of a vehicle.
  • Smoother zooming: continuousZoom: true zooms through fractional zoom levels, also with the scroll wheel. Pinching on a touchpad zooms, and with scrollWheelZoom: 'trackpad', scrolling on a touchpad pans. Tap once, then touch again and drag up or down to zoom (tapDragZoom, enabled by default).
  • Hash keeps the view (and optionally the layers) in the URL, as #zoom/lat/lng, as geo URI or in the query string; MapSync keeps the views of several maps in sync.
  • setCrs() changes the CRS of the map at runtime.
  • The new idle event is fired when the view isn’t changing and all layers have loaded, and view changes caused by the user have the DOM event as originalEvent.
  • The map follows changes of the devicePixelRatio, e.g. when moving the window to another screen, and a hidden map (e.g. in an inactive tab) keeps its view.
  • Accessibility: the map has an accessible name (option ariaLabel), ? shows the keyboard shortcuts, the controls use <button> elements, and popups opened with the keyboard get the focus.
  • Popups and tooltips can extend beyond the map with the overflow option, and tooltips with direction: 'auto' open on the side where they fit.

Layers and markers

  • SvgIcon draws the default marker in any color, with a character, emoji or SVG path as glyph:

      new Marker(latlng, {icon: new SvgIcon({color: 'green', glyph: '🚲'})}).addTo(map);
    
  • HeatLayer draws heatmaps of tens of thousands of points.
  • DisplaceHandler moves overlapping circle markers apart (opt in with MaplairMap.addInitHook('addHandler', 'displace', DisplaceHandler) and the option displace: true).
  • Tile layers: fetchOptions loads tiles with fetch(), e.g. with an Authorization header, and aborts requests of tiles no longer needed; setUrl() swaps the tiles without flickering; placeholder sets a background while tiles load; WMSTileLayer#getLegendUrl() returns the URL of the legend.
  • pixelated: true keeps the pixels of tiles and image overlays crisp when zoomed.
  • setInteractive() changes whether a layer is interactive, and setAttribution() changes its attribution, sorted by the new option attributionOrder.

Optional modules

These features extend existing classes, so they’re separate modules: import them to use them, e.g. import 'maplair/zoom-styles';. The bundles maplair.js and maplair-global.js contain all of them.

  • maplair/map-rotation and maplair/marker-rotation: the rotation of the map and of markers.
  • maplair/marker-movement: Marker#moveTo() and moveAlong() move a marker smoothly along a route.
  • maplair/dash-animation: the path option dashSpeed animates dashes along a path (“marching ants”).
  • maplair/path-decorations: the path options arrowheads and text, e.g. for directions and street names along lines.
  • maplair/geojson-loading: GeoJSON#load() and the option url load GeoJSON from a URL.
  • maplair/zoom-styles: a path weight and a circle marker radius depending on the zoom, as a function or as zoom stops.

Theming

The colors, borders, shadows, fonts and spacing are CSS variables now, so that you can theme maps by overriding them:

:root {
	--maplair-background-color-base: #202122;
	--maplair-color-base: #eaecf0;
}

Migrating from Leaflet 1.9.4

Maplair targets browsers which support the web features that are Baseline Widely available (supported by all major browsers for at least 30 months); Internet Explorer isn’t supported anymore.

Package, files and loading

  • Install the maplair package (npm install maplair), and import from maplair. Maplair is published as ES modules with named exports only — there’s no default export and no global L:

      import {MaplairMap, TileLayer, Marker} from 'maplair';
    
      const map = new MaplairMap('map').setView([51.505, -0.09], 13);
      new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
          maxZoom: 19,
          attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
      }).addTo(map);
      new Marker([51.5, -0.09]).addTo(map);
    

    Without a bundler, use an import map, e.g. pointing maplair to https://cdn.jsdelivr.net/npm/maplair@4.0.0/dist/maplair.js, and <script type="module">.

  • For old-style scripts, dist/maplair-global.js still provides a global L (e.g. new L.MaplairMap('map')), including L.noConflict().
  • The files were renamed: leaflet.js → maplair.js, leaflet-src.js → maplair-src.js, leaflet.css → maplair.css (also importable as maplair/styles.css).
  • All CSS classes were renamed from leaflet-* to maplair-*, e.g. .leaflet-container → .maplair-container, .leaflet-popup → .maplair-popup. Update your stylesheets and any code selecting these classes.
  • The images are SVG files: marker-icon.svg, marker-shadow.svg and layers.svg replace marker-icon.png, marker-icon-2x.png, marker-shadow.png, layers.png and layers-2x.png.

Creating objects

  • The factory functions were removed — use constructors instead: L.marker(latlng) → new Marker(latlng), L.map('map') → new MaplairMap('map'), etc.
  • L.Map is now MaplairMap (Map is still exported as an alias, but shadows JavaScript’s built-in Map when imported).
  • Classes that were attached to other classes are separate exports now:

    Leaflet 1.9.4 Maplair 4.0
    L.Control.Zoom, L.Control.Attribution, L.Control.Layers, L.Control.Scale ZoomControl, AttributionControl, LayersControl, ScaleControl
    L.Map.Drag, L.Map.BoxZoom, L.Map.DoubleClickZoom, L.Map.Keyboard, L.Map.ScrollWheelZoom, L.Map.TapHold, L.Map.TouchZoom DragHandler, BoxZoomHandler, DoubleClickZoomHandler, KeyboardHandler, ScrollWheelZoomHandler, TapHoldHandler, PinchZoomHandler
    L.TileLayer.WMS WMSTileLayer
    L.Icon.Default DefaultIcon
    L.CRS.EPSG3857, L.CRS.EPSG4326, L.CRS.EPSG3395, L.CRS.EPSG900913, L.CRS.Earth, L.CRS.Simple EPSG3857, EPSG4326, EPSG3395, EPSG900913, EarthCRS, SimpleCRS
  • The touch zoom handler was renamed: the map option touchZoom and the property map.touchZoom still work, but are deprecated in favour of pinchZoom.
  • new LatLng(latlng), new Point(point), new LatLngBounds(bounds) and new Bounds(bounds) now always create a new object (Leaflet’s factories returned the given object). To convert without copying, use LatLng.from(), Point.from(), LatLngBounds.from() and Bounds.from(), which return instances as they are, e.g. L.latLng(latlng) → LatLng.from(latlng). LatLng#clone() and Point#clone() were removed — use the constructors instead. Layers like ImageOverlay keep their own copy of the coordinates passed to them.

      Constructor Factory Copy
    Leaflet 1.9.4 new L.LatLng(lat, lng, alt): numbers only L.latLng(x): also arrays and objects, returns LatLng instances as they are latlng.clone()
    Leaflet 2.0 alpha new LatLng(x): also arrays and objects, but returns LatLng instances as they are removed latlng.clone()
    Maplair 4.0 new LatLng(x): also arrays and objects, always a new object LatLng.from(x): also arrays and objects, returns LatLng instances as they are new LatLng(latlng)

    Point, LatLngBounds and Bounds follow the same pattern (in Leaflet, the constructors of the bounds took corner points too, and the bounds had no clone()).

Classes and plugins

  • Class.extend() was removed; use JavaScript classes: class MyLayer extends Layer { … }.
  • initialize() was replaced by the standard constructor(). Call super(…) instead of ParentClass.prototype.initialize.call(this, …).
  • include and statics in class definitions are gone; use MyClass.include({…}) and static members, and configure default options with setDefaultOptions() in a static block.
  • Hooks added with addInitHook() run at the end of the constructor of the class they were added to, which calls this.callInitHooks() — so a hook added to Polyline sees a fully constructed polyline. All Maplair classes support hooks; your own classes do, if they call this.callInitHooks() at the end of their constructor. Hooks of a parent class run before the constructor of a subclass continues. See the tutorial on extending classes.
  • Leaflet plugins import from leaflet and expect leaflet-* CSS classes and the L global, so most won’t work with Maplair unchanged.

Events

  • Maplair uses Pointer Events instead of mouse and touch events. Listen to pointerdown, pointermove, pointerup, pointerover, pointerout etc. instead of mousedown, mousemove, mouseup, mouseover, mouseout, touchstart etc. Maplair logs an error when listening to a removed mouse* event.
  • The aliases addEventListener, removeEventListener, addOneTimeEventListener, fireEvent and hasEventListeners of Evented, and addListener and removeListener of DomEvent, were removed — use on, off, once, fire and listens.
  • DomEvent.getMousePosition() was renamed to DomEvent.getPointerPosition().
  • The map listens to window resizes with a ResizeObserver, so it updates its size when its container changes size for any reason.

Utilities and options

  • Helpers that are now part of JavaScript or the DOM were removed — use the standard APIs instead:
    • Util: bind(), isArray(), create(), trim(), indexOf(), extend(), getParamString(), requestAnimFrame()/cancelAnimFrame(), emptyImageUrl — use Function#bind(), Array.isArray(), Object.create(), String#trim(), Array#indexOf(), Object.assign() or {...}, URLSearchParams, requestAnimationFrame()/cancelAnimationFrame().
    • DomUtil: hasClass(), addClass(), removeClass(), setClass(), getClass(), remove(), empty(), setOpacity(), getStyle(), testProp(), TRANSFORM, TRANSITION, TRANSITION_END — use classList, Element#remove(), Element#replaceChildren(), style/getComputedStyle().
  • Most Browser detection flags were removed (e.g. ie, edge, webkit, android, gecko, opera, any3d, canvas, svg, vml, passiveEvents), as were L_NO_TOUCH, the VML renderer and LayerGroup#invoke() (use eachLayer()).
  • The zoom control options zoomInTitle/zoomOutTitle and the popup option closeButtonLabel were removed; translate these texts with I18n instead.
  • The default attribution prefix links to maplair.org with a 🍁 instead of the Ukrainian flag (CSS class maplair-attribution-leaf instead of maplair-attribution-flag), and the OpenStreetMap attribution added for OpenStreetMap tiles now links the whole text “OpenStreetMap contributors”.
  • Tile layers default to the strict-origin-when-cross-origin referrer policy.
  • The layers control uses <fieldset> instead of <section>.
  • The zoom, layers and fullscreen controls and the popup close button are <button> elements instead of <a href="#">, and the map container gets role="region" and an aria-label unless it has them already.
  • The styles use CSS variables, which changes some details slightly: e.g. the controls are 8px instead of 10px away from the edge of the map, and font sizes are in rem.
  • Tile layers with detectRetina no longer request tiles beyond maxNativeZoom.

Feedback

Please report problems and share feedback on GitHub — and have fun mapping with Maplair! 🍁