Mapbox maps just got a major usability upgrade with the new appearances functionality, making it easier than ever to change icon images based on user interaction—without the verbose, error-prone workarounds of the past.
This screen capture shows appearances in action, displaying different icons for four distinct feature states:
- default icon
- feature hovered
- feature selected
- feature was previously selected
What Are Appearances?
Appearances allow you to define how a symbol layer's icons should change in response to changes in feature state (the ability to set state on specific features on a map). The appearances layer property contains one or more appearance objects defining layout properties like icon-image and the conditions on which to apply them. For example, the following symbol layer defaults to using the image hotel, but includes appearances for 3 additional icons for 3 distinct feature states (currentlySelected, hover, and hasBeenClicked).
{
id: 'points',
type: 'symbol',
source: 'points',
layout: {
'icon-allow-overlap': true,
'icon-image': 'hotel',
'icon-size': 0.75
},
appearances: [
{
name: 'clicked',
condition: [
'boolean',
['feature-state', 'currentlySelected'],
false
],
properties: {
'icon-image': 'hotel-active'
}
},
{
name: 'hovered',
condition: [
'boolean',
['feature-state', 'hover'],
false
],
properties: {
'icon-image': 'hotel-hover'
}
},
{
name: 'has-been-clicked',
condition: [
'boolean',
['feature-state', 'hasBeenClicked'],
false
],
properties: {
'icon-image': 'hotel-clicked'
}
}
]
}
Appearances make the display logic for different states concise and easy to understand. The map style responds in a predictable way to feature state changes, so your frontend code can focus on managing state, not directly updating the map.
To make use of the appearances defined in the symbol layer above, you just need to set feature state as the user interacts with each marker.
For example, when a feature is hovered, set its hover feature state to true using an interaction:
map.addInteraction('hover-in', {
type: 'mouseenter',
target: { layerId: 'points' },
handler: (e) => {
map.setFeatureState(e.feature, { hover: true });
map.getCanvas().style.cursor = 'pointer';
}
});
On click, the process is similar. The currentlySelected feature state for the clicked feature is set to true:
map.addInteraction('click', {
type: 'click',
target: { layerId: 'points' },
handler: (e) => {
// deselect the currently selected feature
if (selectedFeature) {
map.setFeatureState(selectedFeature, {
currentlySelected: false
});
}
// set selectedFeature and add to list of clickedFeatures
selectedFeature = e.feature;
clickedFeatures.push(e.feature);
map.setFeatureState(e.feature, {
currentlySelected: true,
hasBeenClicked: true
});
}
});
You can see the full example outlined above and try it for yourself in the Mapbox docs.
The above example shows frontend implementation for the web using Mapbox GL JS, but the same patterns apply to the Maps SDKs for iOS and Android. See the iOS and Android code examples linked at the bottom of this post.
Zoom-dependent expressions
In addition to using feature state to determine which icon to show, you can also use zoom expressions. The following layer definition uses appearances the icon-size property based on the current zoom:
{
'id': 'points',
'type': 'symbol',
'source': 'points',
'layout': {
'icon-allow-overlap': true,
'icon-image': 'hotel',
'icon-size': 0.5
},
'appearances': [
{
'name': 'zoomed-in',
'condition': ['>=', ['zoom'], 16],
'properties': {
'icon-size': 1.2
}
},
{
'name': 'zoomed-mid',
'condition': ['>=', ['zoom'], 14],
'properties': {
'icon-size': 0.8
}
}
]
}
The Old Way: Updating feature properties
Before appearances, the only way to achieve per-feature icon changes was to store the icon name in each feature's properties and update the entire GeoJSON source every time a state changed. This approach is verbose and requires a lot of client-side code to track state, update properties, and re-set the data source:
{
'id': 'points',
'type': 'symbol',
'source': 'points',
'layout': {
'icon-allow-overlap': true,
'icon-image': ['get', 'icon'],
'icon-size': 0.75
}
}
function setFeatureIcon(id, icon) {
const source = map.getSource('points');
const data = getSourceData();
if (!data) return;
const feature = data.features.find(f => f.id === id);
if (feature) {
feature.properties.icon = icon;
source.setData(data);
}
}
This method is not only cumbersome, but it also does not work with vector tiles, since you cannot update the data on a vector tile source. This limitation made dynamic icon styling impossible for symbol layers visualizing a vector source.
Why Appearances Are a Game Changer
- Declarative: Define all your icon state logic in one place, right in the layer definition.
- No data changes required: No more manual property management or data source updates.
- Works with vector tiles: Since appearances are based on feature-state, you can now have dynamic icons on vector tile sources, unlocking new possibilities for large-scale, performant maps.
- Cleaner code: Less boilerplate, fewer bugs, and easier to maintain.
Try It Out and Share Feedback!
We invite you to test out the new appearances feature in your projects to make symbol layers that respond to feature state or zoom changes.
Have feedback or ideas? Let us know—your input helps us make Mapbox maps even better!
Additional resources
- Appearances Reference - Mapbox Style Specification
- Dynamic symbol styling: New ways to configure icon state
You can also explore full working examples in the official documentation:


Top comments (0)