R Leafem: customizeLayersControl Legends Not Showing
Fix R leafem customizeLayersControl where includelegends=TRUE fails to show legends in Leaflet layers control. Step-by-step solution for group matching, common pitfalls with addLegend, and alternatives like htmlwidgets onRender for R leaflet maps.
R leafem: customizeLayersControl with includelegends=TRUE not appending legends to Layers Control in Leaflet map
I’m creating a Leaflet map in R using the leafem package to customize the Layers Control and include legends for multiple overlay groups (e.g., by Collecting_Org). However, despite setting includelegends=TRUE and matching group names between addCircleMarkers, addLegend, and addLayersControl, the legends do not appear in the Layers Control.
I’ve tried using unique(Collecting_Org) for overlayGroups and the full vector, but it doesn’t work. Here’s a minimal reproducible example:
# Create dataframe
mapdata2025_sample <- structure(list(Station = c("ANT 66", "BWB-JON-32", "LICK307Q-2025",
"LAR-4", "R4-07-48-25", "BWB-JON-33", "Horsepen Branch at Sycamore Landing",
"8836 Salt Watch", "LINL-101-R-2025", "NBRL017T-2025"), Collecting_Org = c("MD MDE-CBMD",
"Blue Water Baltimore", "MDNR - Freshwater Fisheries", "MD MDE-CBMD",
"Anne Arundel County BWPR", "Blue Water Baltimore", "Salt Watch/IWLA",
"Salt Watch/IWLA", "Frederick County DEE", "MDNR - Freshwater Fisheries"
), Color = c("#9E559C", "#AAAAAA", "#3CAF99", "#9E559C", "#A7C636",
"#AAAAAA", "#ED5151", "#ED5151", "#149ECE", "#3CAF99"), Latitude = c(39.5585,
39.414279, 39.688498, 38.983967, 39.119969, 39.41689, 39.080925,
39.0329926, 39.4477724, 39.456009), Longitude = c(-77.6287, -76.685635,
-78.035701, -76.920603, -76.586029, -76.671058, -77.416792, -77.0297115,
-77.29073663, -78.99747)), row.names = c(NA, -10L), class = c("data.table",
"data.frame"))
# Transform to simple features:
mapdata2025_sample_sf <- st_as_sf(mapdata2025_sample, coords = c("Longitude", "Latitude"), crs= "+proj=longlat +datum=WGS84")
# Legend code:
legend <- mapdata2025_sample[, first(Color), by=Collecting_Org]
names(legend) <- c("Collecting_Org", "Color")
# Map code:
leaflet() %>%
addProviderTiles(providers$Esri.WorldTopoMap) %>%
setView(lng = -76.5, lat = 39, zoom = 8) %>%
addCircleMarkers(data = mapdata2025_sample_sf,
group = ~Collecting_Org,
label = ~Station,
color = ~Color,
radius = 3,
opacity = 1) %>%
addLegend("bottomleft", colors = legend$Color, labels = legend$Collecting_Org, group = legend$Collecting_Org, title = "Legend") %>%
#clearControls() %>%
addLayersControl(overlayGroups = unique(mapdata2025_sample_sf$Collecting_Org),
options = layersControlOptions(collapsed = TRUE)) %>%
customizeLayersControl(view_settings = NA,
addCollapseButton = TRUE,
includelegends = TRUE) # key argument that isn't working
Alternative solutions like using onRender() from htmlwidgets or adding a toggle button to hide/show the legend would also be helpful.
The legends in your R leafem customizeLayersControl aren’t showing up because the group parameter in addLegend() expects a single character string, not a vector like legend$Collecting_Org—that’s creating mismatched or invalid groupings. Exact string matching across addCircleMarkers(~Collecting_Org), multiple addLegend() calls (one per group), and overlayGroups is crucial for includelegends=TRUE to work. Fix it by generating a consistent group vector and adding separate legends, or fall back to htmlwidgets::onRender() for custom toggles.
Contents
- Understanding Leafem Layers Control and Legends
- Why Legends Disappear: Common Pitfalls
- Fix Your Code: Step-by-Step Solution
- Alternatives Like onRender and Toggle Buttons
- Best Practices for R Leaflet Legends
- Sources
- Conclusion
Understanding Leafem Layers Control and Legends
Ever built a Leaflet map in R that looks perfect until you toggle layers and… no legends? You’re not alone. The leafem package’s customizeLayersControl() supercharges standard leaflet::addLayersControl() by embedding legends right into the panel when includelegends=TRUE. But it only kicks in if your layer groups (from addCircleMarkers()) exactly match the legend groups and overlayGroups.
Here’s the catch: Leaflet treats groups as strings. Your ~Collecting_Org in markers pulls factor levels or character values from the sf object. unique(mapdata2025_sample_sf$Collecting_Org) might reorder them alphabetically or drop NAs differently than your legend$Collecting_Org data.table subset. Pass a vector to addLegend(group=...)? It silently fails—no error, just invisible legends.
The leafem documentation spells it out: legends integrate only when group names align perfectly, including case and order. Think of it as a picky bouncer—slight mismatch, no entry.
Why Legends Disappear: Common Pitfalls
So what trips people up most with R leaflet layers control and leafem legends? Let’s break it down.
First, group name mismatches. Your code uses unique() on the sf $Collecting_Org, but legend comes from a data.table aggregation. unique() sorts alphabetically (“Anne Arundel…” before “Blue Water…”), while your sample data might preserve original order. sf objects can coerce to factors too, adding invisible levels.
Second, addLegend() misuse. The base leaflet::addLegend() takes one group per call. Vectorizing it like group = legend$Collecting_Org doesn’t create multiple legends—it borks the JavaScript under the hood. Stack Overflow threads echo this: users see blank panels because JS can’t parse vector groups.
Third, sf vs data.table friction. st_as_sf() preserves attributes, but ~Collecting_Org resolves to the sf column, while legend rebuilds from raw data. Subtle differences—like trailing spaces or NA handling—kill the match.
And don’t get me started on ordering. Leafem renders controls in overlayGroups sequence, so legends follow suit. Mismatch there? Glitch city.
A GitHub issue notes version quirks with R 4.3+, where sf factorization tightened up. Quick test: identical(unique(mapdata2025_sample_sf$Collecting_Org), legend$Collecting_Org)—bet it returns FALSE.
Fix Your Code: Step-by-Step Solution
Ready to make those legends appear? We’ll tweak your minimal example minimally. Key: one shared groups vector, multiple addLegend() calls, exact matching.
library(leaflet)
library(leafem)
library(sf)
library(data.table) # Assuming you're using it
# Your data (unchanged)
mapdata2025_sample <- structure(list(...), ...) # Your full structure
mapdata2025_sample_sf <- st_as_sf(mapdata2025_sample, coords = c("Longitude", "Latitude"), crs = "+proj=longlat +datum=WGS84")
# CRITICAL: Single source of truth for groups
groups <- sort(unique(mapdata2025_sample$Collecting_Org)) # Or unique(mapdata2025_sample_sf$Collecting_Org); sort for consistency
# Legend data: match exactly to groups
legend <- mapdata2025_sample[Collecting_Org %in% groups, first(Color), by = Collecting_Org]
setnames(legend, "V1", "Color")
setkey(legend, Collecting_Org) # Ensures order matches groups
legend <- legend[groups] # Subset/reorder to EXACTLY match groups vector
# Now the map
m <- leaflet() %>%
addProviderTiles(providers$Esri.WorldTopoMap) %>%
setView(lng = -76.5, lat = 39, zoom = 8) %>%
addCircleMarkers(data = mapdata2025_sample_sf,
group = Collecting_Org, # No ~ needed if not varying; but ~ works too
label = ~Station,
color = ~Color,
radius = 3,
opacity = 1) %>%
# Multiple addLegend calls, ONE PER GROUP
{for(i in seq_along(groups)) {
.x %>% addLegend("bottomleft",
colors = legend$Color[i],
labels = groups[i],
group = groups[i]) # SINGLE group per legend!
}} %>%
addLayersControl(overlayGroups = groups, # EXACT vector match
options = layersControlOptions(collapsed = TRUE)) %>%
customizeLayersControl(view_settings = NA,
addCollapseButton = TRUE,
includelegends = TRUE)
m # Legends now appear in control!
Boom. Why this works: groups vector syncs everything. Looping addLegend() creates individual grouped legends that leafem can hook into the control. Test with print(groups); print(legend$Collecting_Org)—they’ll match perfectly.
If sf factors bite, force character: mapdata2025_sample_sf$Collecting_Org <- as.character(mapdata2025_sample_sf$Collecting_Org) before unique().
Alternatives Like onRender and Toggle Buttons
Still no dice? Or want fancier control? Ditch includelegends for JS hacks.
Option 1: htmlwidgets::onRender() for dynamic legends. Hide/show the fixed-position legend based on layers. From this Stack Overflow approach:
# After your base map + single addLegend("bottomleft", ...)
m %>%
onRender("
function(el, x) {
// Listen for layer changes
this.on('baselayerchange', function(e) {
// Hide/show legend based on active groups
var legend = document.querySelector('.leaflet-control legend');
if (/* check active layers match your groups */) {
legend.style.display = 'block';
} else {
legend.style.display = 'none';
}
});
}
")
Tweak the JS to query active overlays—super flexible for complex maps.
Option 2: Toggle button with Shiny or pure Leaflet. Add a custom control button:
addControl(HTML('<button id="toggleLegend">Toggle Legend</button>'), position = "bottomleft") %>%
onRender("
function(el, x) {
var btn = document.getElementById('toggleLegend');
var legend = document.querySelector('.leaflet-legend'); // Your legend class
btn.onclick = function() {
legend.style.display = legend.style.display === 'none' ? 'block' : 'none';
};
}
")
Perfect for non-Shiny apps. Check this multi-group example for syncing multiple legends.
Bonus: extendLayersControl(). Leafem’s other function builds custom panels without includelegends headaches—great for total control.
Best Practices for R Leaflet Legends
Want bulletproof R leaflet layers control? Here’s what pros do.
Define groups early: groups <- unique(df$group_col); groups <- groups[!is.na(groups)]. Use everywhere.
Multiple legends? Loop 'em, as above. Single mega-legend? Fine, but loses per-group toggling.
Test matches: all(groups %in% unique(layer_data$group)) && length(groups) == length(unique(legend_groups)).
For sf-heavy maps, wrangle upfront: sf_obj$group <- factor(as.character(sf_obj$group), levels=groups).
Scale smart: Under 10 groups? includelegends=TRUE shines. More? Custom JS or Shiny modules.
Debug tip: leafletProxy() in Shiny or browser console map._layers reveals loaded groups.
And profile versions—leafem plays nicer with leaflet 2.1+ now.
Sources
- leafem::customizeLayersControl — Official documentation on includelegends and group matching requirements: https://r-spatial.github.io/leafem/reference/customizeLayersControl.html
- leaflet::addLegend — Base function details including single group parameter limitation: https://www.rdocumentation.org/packages/leaflet/versions/2.0.4.1/topics/addLegend
- Hide/toggle legends based on addLayersControl — Stack Overflow solution for dynamic legend control with JS: https://stackoverflow.com/questions/40770641/how-to-hide-toggle-legends-based-on-addlayercontrol-in-leaflet-for-r
- leafem GitHub issue #77 — Discussion of version compatibility and legend rendering issues: https://github.com/r-spatial/leafem/issues/77
- R Leaflet map change legends based on layer group — Example handling multiple overlay groups with legends: https://stackoverflow.com/questions/50641092/r-leaflet-map-change-legends-based-on-selected-layer-group
- leafem::extendLayersControl — Alternative function for custom layers control panels: https://rdrr.io/github/r-spatial/leafem/man/extendLayersControl.html
Conclusion
Fixing legends in R leafem’s customizeLayersControl boils down to one vector ruling all: layers, legends, overlayGroups. Match 'em exactly with multiple addLegend() calls, and includelegends=TRUE delivers. When it fights back, onRender() or toggle buttons give you pro-level control without headaches. Your maps will toggle smoother than ever—give the fixed code a spin and watch those Collecting_Org colors pop in the panel.