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.
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 paintsdark_matteron 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.
Related
- Base Layer Selection & Switching — parent guide on provider choice, layer control, and switch UX
- Switching Between OpenStreetMap and Mapbox Basemaps at Runtime — the general runtime swap pattern this toggle specializes for light/dark
- Best Base Map Providers for High-Contrast Geo-Dashboards — choosing basemaps whose contrast keeps overlays readable in both themes
- Making Python-Generated Maps Responsive on Mobile — placing the theme control so it survives small-screen layouts