
Vector maps for flutter_map, drawn with the plain Flutter canvas, in any MapLibre style.
- Reads any MapLibre style: colours, roads, labels, icons, dashes, patterns.
- Runs on the stable channel, on Skia or Impeller. No GPU package.
- Redraws from vectors every frame, so nothing goes soft between zoom levels.
- Tiles are loaded and built on background isolates.
- Labels in any script, in your app's own fonts.
- Its own gestures, with rotation, and optional round pins for places.
- Your own GeoJSON drawn with the style, and a lookup of what is under a tap.
Install
flutter pub add vmap flutter_map latlong2
Quick start
import 'package:latlong2/latlong.dart';
import 'package:vmap/vmap.dart';
VmapMap(
initialCenter: const LatLng(36.19, 44.01),
initialZoom: 14,
)
That is a full map: the built in style, tiles from OpenFreeMap (no API key), and gestures.
Already using flutter_map?
Keep your FlutterMap and swap the tile layer:
FlutterMap(
options: MapOptions(initialCenter: LatLng(36.19, 44.01), initialZoom: 14),
children: [
- TileLayer(urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'),
+ VmapLayer(),
MarkerLayer(markers: markers),
],
)
VmapMap and VmapLayer take the same options below. The snippets leave out
initialCenter to stay short.
Styles

VmapMap(style: VmapStyle.light) // built in, the default
VmapMap(style: VmapStyle.dark) // built in
VmapMap(style: VmapStyle.url('https://tiles.openfreemap.org/styles/liberty'))
VmapMap(style: VmapStyle.json(jsonDecode(myStyle)))
The style decides the look and where the tiles come from. Light and dark versions of one style switch without reloading a tile.

Changing a layer
To recolour, hide or filter one layer of a style without writing the whole
style out again, name the layer by its id:
VmapMap(
style: VmapStyle.url('https://tiles.openfreemap.org/styles/liberty'),
styleEdits: [
const VmapStyleEdit.paint('water', 'fill-color', '#2a6fdb'),
const VmapStyleEdit.visible('building', false),
const VmapStyleEdit.filter('poi_r1', ['==', 'class', 'hospital']),
VmapStyleEdit.zoomRange('road_minor', min: 14),
],
)
These are MapLibre's setPaintProperty, setLayoutProperty, setFilter
and setLayerZoomRange, given as a list instead of called. A change of
colour, width or opacity keeps the tiles already loaded; a filter, a zoom
range or a layout property builds them again behind what is on screen.
Layers of your own are changed the same way, or by passing a new overlay.
Tiles from one file
A source whose address starts with pmtiles:// is read from a
PMTiles archive: a whole tile set in
one file, on any web server that answers range requests or on the device
itself. It works for vector, raster and hillshade sources.
'sources': {
'city': {
'type': 'vector',
'url': 'pmtiles://https://example.org/city.pmtiles',
},
'offline': {
'type': 'vector',
'url': 'pmtiles:///data/user/0/my.app/files/region.pmtiles',
},
}
An archive on the device needs no network at all, which is the way to ship a map that works offline. Archives packed with gzip are read; brotli and zstd are not.
Pins
Styles draw places with small sprite icons. Pass pins to draw them as round
pins instead:
VmapMap(pins: VmapPins.standard)

The standard set covers the usual kinds of place: food, cafes, hotels, shops,
parks, sights, health, transport. It uses Material icons, so the app needs
uses-material-design: true in its pubspec.
To change it:
const pins = VmapPins(
size: 26,
byIcon: {
...standardPins,
'cafe': VmapPin(Icons.coffee_rounded, Color(0xFF6F4E37)),
},
);
A pin is looked up by the icon name the style gives a place. For OpenMapTiles
styles that is the place's class, such as cafe or lodging. Places with no
pin of their own get other, a grey pin; set other: null to leave them
with the style's icon.
These pins are part of the map and do not take taps. For places the user can tap, use markers.
Markers
Markers are flutter_map Markers. Map labels keep out of their way. A
NamedMarker also gets its name written beside it, wherever there is room.
VmapMap(
markers: [
NamedMarker(
name: 'Machko Chaikhana',
point: const LatLng(36.18946, 44.00970),
width: 34,
height: 34,
child: const CafePin(),
),
],
)
With VmapLayer, pass the same list to both layers:
VmapLayer(markers: markers),
MarkerLayer(markers: markers),
Gestures
VmapMap comes with its own gestures: drag with a glide, pinch around the
fingers, turn with two fingers, double tap, two finger tap, and double tap
then drag. Labels stay upright while the map turns. rotate: false switches
turning off.
Move the map from code with a flutter_map MapController:
final controller = MapController();
VmapMap(controller: controller, onTap: (point) {})
controller.move(const LatLng(36.19, 44.01), 15);
To get the same gestures on your own FlutterMap, wrap it in VmapGestures
and set InteractionOptions(flags: InteractiveFlag.none). flutter_map's own
gestures also work with VmapLayer.
Your own data
An overlay adds sources and layers of your own to whatever style is showing.
Both are written the way a MapLibre style writes them, so anything a style
can draw, you can draw: lines, fills, circles, text, icons, heatmaps.
const route = VmapOverlay(
sources: {
'route': {
'type': 'geojson',
'data': {
'type': 'Feature',
'properties': {'name': 'Evening walk'},
'geometry': {
'type': 'LineString',
'coordinates': [[44.0053, 36.1930], [44.0087, 36.1890]],
},
},
},
},
layers: [
{
'id': 'route',
'type': 'line',
'source': 'route',
'layout': {'line-cap': 'round'},
'paint': {'line-color': '#1A73E8', 'line-width': 5},
},
],
);
VmapMap(overlay: route)
A layer with 'before': 'some-layer-id' is drawn under that layer of the
style instead of on top. data may also be the address of a GeoJSON file.
To change what is shown, build a new overlay with a new data object. Only
that source is read again, and what it drew stays up until the new data is
ready, so a dot that moves a few times a second does not blink. Keep the
overlay between builds: a new one in every build is treated as a change
every time.
Many points
A GeoJSON source with 'cluster': true gathers its points into clusters
that split apart as the map zooms in, with MapLibre's options and
properties:
VmapOverlay(
sources: {
'shops': {
'type': 'geojson',
'data': shops,
'cluster': true,
'clusterRadius': 45,
'clusterMaxZoom': 14,
},
},
layers: [
{
'id': 'clusters',
'type': 'circle',
'source': 'shops',
'filter': ['has', 'point_count'],
'paint': {
'circle-color': '#1A73E8',
'circle-radius': ['step', ['get', 'point_count'], 14, 20, 19, 80, 25],
},
},
{
'id': 'counts',
'type': 'symbol',
'source': 'shops',
'filter': ['has', 'point_count'],
'layout': {
'text-field': ['get', 'point_count_abbreviated'],
'text-allow-overlap': true,
},
'paint': {'text-color': '#FFFFFF'},
},
{
'id': 'shop',
'type': 'circle',
'source': 'shops',
'filter': ['!', ['has', 'point_count']],
'paint': {'circle-color': '#1A73E8', 'circle-radius': 5},
},
],
)
Tapping a cluster finds it like any other feature. Ask the map's engine,
through a GlobalKey<VmapMapState> on the map, where it comes apart and
move there, or list what it holds:
final engine = mapKey.currentState!.engine!;
final id = feature.properties['cluster_id']! as int;
final zoom = await engine.clusterExpansionZoom('shops', id);
if (zoom != null) mapController.move(tapped, zoom);
final shops = await engine.clusterLeaves('shops', id, limit: 20);
With your own FlutterMap, a GlobalKey<VmapLayerState> on the layer
gives the same engine as key.currentState!.engine.
What is under a tap
VmapMap(
onFeatureTap: (point, features) {
if (features.isEmpty) return;
final top = features.first;
print('${top.properties['name']} from ${top.layers.first}');
},
)
features holds what the style draws at that point, topmost first: roads,
buildings, places, and the features of your overlay, each with its
properties, its source and the ids of the style layers drawing it. The answer
comes from the tiles off the UI thread, so it arrives a moment after the tap.
With your own FlutterMap, give the layer a GlobalKey<VmapLayerState> and
call key.currentState!.engine.featuresAt(point).
For everything in an area instead of at a point, for a selection box or a list of what is on screen:
final shown = await mapKey.currentState!.engine!.featuresIn(
southWest,
northEast,
);
It returns what touches the box. A feature with an id comes once, the id being taken as its own within its source layer, the way feature state takes it. One without an id that lies over the edge of a tile comes once for each tile it is in.
Highlighting it
A paint property can read state you give a feature, with
['feature-state', 'picked']. Changing the state repaints and rebuilds
nothing:
'paint': {
'fill-color': [
'case',
['==', ['feature-state', 'picked'], true], '#E5484D',
'#1A73E8',
],
},
VmapMap(
featureStates: [
if (picked != null) VmapFeatureState.of(picked, const {'picked': true}),
],
onFeatureTap: (_, features) =>
setState(() => picked = features.firstOrNull),
)
The feature needs an id: the id of a GeoJSON feature, or the id its vector
tile carries. 'promoteId': 'some_property' on the source uses a property
as the id instead, and 'generateId': true on a GeoJSON source numbers its
features. A layer painted this way is drawn one feature at a time, so
keep it for layers with hundreds of features in view, not tens of thousands.
Fonts and language
Labels use the fonts your app bundles. Sizes and colours come from the style.
VmapMap(
textStyle: const TextStyle(
fontFamily: 'Inter',
fontFamilyFallback: ['Vazirmatn'],
),
)
nameKeys picks the label language, tried in order:
VmapStyle.url(styleUrl, nameKeys: ['name:en', 'name'])

Cache
Give it a directory and tiles and the style are kept between runs. The next start draws from disk at once and checks the server in the background.
VmapMap(cacheDirectory: '${cacheDir.path}/vmap')
clearCache() on the engine deletes what the map kept there and loads it
again. reload() loads again without deleting.
A region for offline use
downloadRegion on the engine fetches every tile
the style draws for an area into the same directory, so that part of the
map shows with no network:
final result = await mapKey.currentState!.engine!.downloadRegion(
west: 43.9, south: 36.1, east: 44.1, north: 36.3,
minZoom: 10, maxZoom: 16,
onProgress: (done, total) => setState(() => progress = done / total),
);
print('${result.fetched} fetched, ${result.failed} failed');
Tiles already kept are skipped, so a download that was cut short carries on where it stopped. Each zoom has four times the tiles of the one before: look at the size of a small range first, and at the terms of the tile server before downloading in bulk. The style and its icons are kept the first time the map shows them. There is nothing to download into in a browser.
Servers that want a key
headers go out with every request: the style, its tiles, sprites and
archives. onError hears of anything that could not be loaded. The map keeps
retrying on its own, so this is for logging or for telling the user.
VmapMap(
style: const VmapStyle.url('https://maps.example.org/style.json'),
headers: {'Authorization': 'Bearer $token'},
onError: (error) => log('map: $error'),
)
A source that lists several tiles addresses has its tiles spread over
them, and the next one is asked when one fails.
Related packages
Other ways to draw vector maps in Flutter, as their own pages describe them in October 2026:
| Package | What it is | Draws with | Web |
|---|---|---|---|
| vector_map_tiles | flutter_map layer | 9.x: tiles rendered to images. 10.x beta: flutter_gpu |
not listed |
| flutter_map_vector_tiles | flutter_map layer, a rewrite of vector_map_tiles' ideas | tiles drawn once into images, labels each frame | yes |
| flutter_map_maplibre | flutter_map layer | MapLibre's own renderer | yes |
| flutter_map_maplibre_gpu | flutter_map layer | Flutter GPU, through maplibre_flutter_gpu | no |
| maplibre_gl | a map widget of its own | MapLibre's own renderer, MapLibre GL JS on the web | yes |
| maplibre | a map widget of its own | MapLibre's own renderer, MapLibre GL JS on the web | yes |
| maplibre_flutter_gpu | a map widget of its own | Flutter GPU | no |
vmap has been measured against one of them so far.
vmap and vector_map_tiles
vector_map_tiles is the established vector layer for flutter_map.
| vmap | vector_map_tiles | |
|---|---|---|
| Drawing | vectors, every frame, with Canvas |
9.x: tiles rendered to images. 10.x beta: flutter_gpu |
| Flutter channel | stable | 9.x: stable. 10.x beta: main, with Flutter GPU on |
| Renderer | Skia or Impeller | 10.x beta needs Impeller |
| Hillshade layers | yes | yes |
| Rotation | yes | yes |
| Labels that avoid your markers | yes | no |
| Pins for places | yes | no |
The 10.x rows are taken from that version's own README.
On one Android phone at 60 Hz, flying the same tour over the same style, vmap lost 0 frames in each of four runs and vector_map_tiles 9.0.0-beta.13 lost between 10 and 196. vmap does more work per frame to get there. The full table, the caveats and how to run it are in doc/benchmark.md.
Limits
- No 3D: buildings are flat and the map does not pitch.
- Fonts come from the app, not from the style's glyph server.
- On the web there is no disk cache and a PMTiles archive has to be at an
https://address. It has been run in Chrome only. Use the default JavaScript build: the--wasmbuild froze its tab in three of about a dozen runs and showed labels with letters missing for a moment. Label halos in the JavaScript build are the text's outline, so they look slightly sharper than on a phone. - Run so far on one Android phone, on macOS and in Chrome. iOS and low end devices are untested.
The full list of what is read from a style, and how the renderer works, is in doc/style-support.md.
On the web
vmap runs in a browser. There Dart has one thread and nothing is kept on disk. To draw with MapLibre GL JS in the browser instead, flutter_map_maplibre offers a MapLibre layer for flutter_map, and maplibre_gl a MapLibre map of its own. Both read the same styles as vmap.
Tiles are built in a web worker that ships with vmap, so drawing a new
area does not hold up the page, with nothing to set up. Set
VmapEngine.webWorker = null before the first map to build them on the
page instead; a worker that does not load falls back to the page by itself.
Attribution
Show the credit your tile source asks for. For OpenFreeMap that is
© OpenFreeMap © OpenMapTiles © OpenStreetMap. attributions on the
engine lists what the sources of a style ask for in their attribution.
Example
cd example
flutter run

A map of Erbil's old town with a bar across the top: the style, the place icons (none, the style's own, or pins), and layers to switch on, such as a walking route and clusters. example/README.md lists its switches.
License
MIT. The example bundles Google Sans Flex and Vazirmatn under the SIL Open Font License. Place names in the example and the pictures are from OpenStreetMap.
Libraries
- vmap
- web_worker
- The other half of VmapEngine.webWorker: what the worker script runs.