Adding a Dark-Mode Basemap Toggle to a Leaflet Dashboard

Part of the Base Layer Selection & Switching guide.

Operative rule: register both CartoDB positron and CartoDB dark_matter as named tile layers at generation time, then let a small injected script pick between them from prefers-color-scheme — never regenerate the map to change theme.

How the Toggle Works

folium is a Python wrapper that emits a self-contained Leaflet page. When you add two folium.TileLayer objects to the same map, both are serialized into the output HTML as L.tileLayer instances, but only the first is attached to the map on load. The theme toggle is therefore a client-side decision: a few lines of JavaScript choose which of the two pre-declared tile layers is active. Because both layers share the EPSG:3857 Web Mercator grid, swapping between them is instantaneous and leaves the viewport, zoom, and every data overlay untouched — Leaflet simply repaints the tile canvas underneath your markers.

The theme signal comes from the CSS prefers-color-scheme media query, which reflects the operating system’s light/dark setting. The injected script reads window.matchMedia('(prefers-color-scheme: dark)') on first paint to pick the initial basemap, then listens for its change event so a user flipping their OS theme re-syncs the map live. This is the same pre-declare-then-swap pattern used when switching between OpenStreetMap and Mapbox basemaps at runtime, narrowed to a two-state light/dark case. Overlay legibility is the one thing that does not come for free: colors tuned for positron can wash out on dark_matter, so contrast handling is part of the toggle, not an afterthought — a concern shared with the best base map providers for high-contrast geo-dashboards.

Dark-Mode Basemap Toggle Flow The prefers-color-scheme media query feeds a toggle handler that selects either the CartoDB positron light tile layer or the CartoDB dark_matter dark tile layer. The overlay layer sits above both and switches its stroke color to stay legible. prefers-color-scheme OS light / dark signal toggle handler select tile layer CartoDB positron (light) overlays get dark stroke CartoDB dark_matter (dark) overlays get white stroke

Production-Ready Implementation

The Python below builds the map with both basemaps registered as named layers, adds a GeoJSON overlay with a theme-aware style, and injects a small script through folium.Element. The injected code picks the initial basemap from prefers-color-scheme, wires an optional in-map button, and re-syncs on OS theme changes.

from __future__ import annotations

import folium
from branca.element import Element


def build_dark_toggle_map(geojson_path: str, out_path: str) -> str:
    """Generate a Folium dashboard with a light/dark basemap toggle."""
    # Start with no default tiles so we control layer order explicitly.
    m = folium.Map(
        location=[40.7128, -74.0060],
        zoom_start=12,
        tiles=None,
        control_scale=True,
    )

    # Two named basemaps on the shared EPSG:3857 grid. name= is the handle
    # the injected JS uses to find each layer.
    folium.TileLayer("CartoDB positron", name="light-basemap").add_to(m)
    folium.TileLayer("CartoDB dark_matter", name="dark-basemap").add_to(m)

    # Theme-aware overlay: a contrasting stroke keeps points legible on both
    # basemaps. The fill stays branded; only the outline flips.
    def style_fn(_feature: dict) -> dict:
        return {
            "radius": 6,
            "fillColor": "#f97316",   # brand orange — readable on both
            "color": "#111111",       # default (light) stroke; JS flips it
            "weight": 1.5,
            "fillOpacity": 0.9,
        }

    folium.GeoJson(
        geojson_path,
        name="incidents",
        marker=folium.CircleMarker(),
        style_function=style_fn,
    ).add_to(m)

    folium.LayerControl(collapsed=True).add_to(m)

    # Inject the theme sync. m.get_name() yields the JS variable name Folium
    # assigned to the map object in the output HTML.
    map_var = m.get_name()
    toggle_js = f"""
    <script>
    (function () {{
      const mapEl = {map_var};
      const mq = window.matchMedia('(prefers-color-scheme: dark)');

      // Collect the two tile layers by their attribution-independent order.
      const bases = {{ light: null, dark: null }};
      mapEl.eachLayer((layer) => {{
        const url = layer._url || '';
        if (url.includes('light_all')) bases.light = layer;   // positron
        if (url.includes('dark_all')) bases.dark = layer;     // dark_matter
      }});

      function applyTheme(isDark) {{
        const on = isDark ? bases.dark : bases.light;
        const off = isDark ? bases.light : bases.dark;
        if (off && mapEl.hasLayer(off)) mapEl.removeLayer(off);
        if (on && !mapEl.hasLayer(on)) on.addTo(mapEl);
        // Keep overlay strokes legible: white on dark, near-black on light.
        mapEl.eachLayer((layer) => {{
          if (layer.setStyle && layer.feature) {{
            layer.setStyle({{ color: isDark ? '#ffffff' : '#111111' }});
          }}
        }});
        document.body.dataset.theme = isDark ? 'dark' : 'light';
      }}

      applyTheme(mq.matches);                     // first paint follows OS
      mq.addEventListener('change', (e) => applyTheme(e.matches));  // live sync
    }})();
    </script>
    """
    m.get_root().html.add_child(Element(toggle_js))

    m.save(out_path)
    return out_path


if __name__ == "__main__":
    build_dark_toggle_map("data/incidents.geojson", "dist/dashboard.html")

Alternative Variants

A manual button instead of OS sync

When the dashboard lives inside a host app that already exposes its own theme switch, drive the map from a button rather than prefers-color-scheme. Add this to the injected script and skip the matchMedia listener. It composes with responsive work covered in making Python-generated maps responsive on mobile, where a fixed-position button must not overlap map controls on small screens.

// Add inside the injected IIFE, replacing the matchMedia block.
let dark = false;
const btn = L.control({ position: "topright" });
btn.onAdd = function () {
  const b = L.DomUtil.create("button", "theme-btn");
  b.textContent = "◐ Theme";
  b.style.cssText = "padding:6px 10px;cursor:pointer;font:inherit;";
  L.DomEvent.on(b, "click", (e) => {
    L.DomEvent.stop(e);          // stop the click from panning the map
    dark = !dark;
    applyTheme(dark);
  });
  return b;
};
btn.addTo(mapEl);
applyTheme(false); // start light; button toggles from there

Basemap and overlay contrast reference

Element Light theme (positron) Dark theme (dark_matter)
Basemap tile preset CartoDB positron CartoDB dark_matter
URL fragment to match light_all dark_all
Marker fill #f97316 #f97316 (unchanged)
Marker stroke #111111 #ffffff
Line / polygon stroke #1f2937 #e5e7eb
body[data-theme] value light dark

Verification Steps

  • OS sync on load — set your OS to dark mode, open the saved .html, and confirm the map paints dark_matter on first render without any click.
  • Live re-sync — with the page open, flip the OS theme and confirm the basemap switches within a second and the overlay strokes recolor.
  • Viewport preserved — pan and zoom, then toggle; the center and zoom must not move when the basemap swaps.
  • Overlay contrast — inspect a marker on both themes; the stroke must be visibly distinct from the tile background in each case (white on dark, dark on light).
  • LayerControl consistency — open the layer control and confirm exactly one basemap is checked at a time, matching the active theme.

Common Errors & Fixes

Both basemaps render stacked on top of each other

Both folium.TileLayer objects were added as always-on layers, so Leaflet paints them simultaneously and the toggle only ever adds a third copy. Ensure the injected applyTheme always calls removeLayer on the inactive basemap before addTo on the active one. Adding the layers as a folium.LayerControl radio group (base layers are mutually exclusive) also prevents the stack.

The toggle finds no tile layers (bases.light is null)

The _url matching strings drifted from CartoDB’s actual endpoints. Positron tiles contain light_all and dark_matter tiles contain dark_all in their URL template; if CartoDB changes the path, log layer._url for each layer and update the includes(...) checks. Relying on the URL fragment is more robust than layer order, which the LayerControl can reorder.

Markers disappear on the dark basemap

The overlay stroke and fill were both chosen for a light background, so they vanish against dark_matter. Flip the stroke color from the applyTheme handler (white on dark, near-black on light) and keep a mid-saturation fill that reads on both. Never rely on fill alone for legibility — a contrasting outline is what carries the marker across themes.

prefers-color-scheme never triggers inside an embedded iframe

An <iframe> inherits the media query from the top document, but a sandboxed frame may block script execution that reads it. Confirm the frame is not sandbox-restricted beyond allow-scripts, and if the host app owns the theme, switch to the manual-button variant driven by a postMessage from the parent instead of matchMedia.