Skip to content

Map Styles

A MapLibre style is a JSON document that defines everything the map renders: which tile sources to fetch, which layers to draw, and how to style them. Understanding styles lets you switch map themes, load offline tiles, and integrate PMTiles.

What a style contains

flowchart LR
    S["style.json"]
    S --> V["version: 8"]
    S --> SRC["sources<br/><small>where data comes from<br/>(tile URLs, GeoJSON, ...)</small>"]
    S --> L["layers<br/><small>what to draw and how<br/>(colors, widths, icons)</small>"]
    S --> SP["sprite<br/><small>icon spritesheet URL</small>"]
    S --> GL["glyphs<br/><small>font glyph URL template</small>"]
    S --> M["metadata<br/><small>optional, ignored by renderer</small>"]

    classDef root fill:#1f6feb,stroke:#1a5fd0,color:#fff;
    class S root

MapLibre fetches tiles, images, and fonts as needed and renders them using the GPU, all described by this one JSON file.

Built-in styles

The library ships two convenience constants in MapLibreStyles:

import 'package:maplibre_gl/maplibre_gl.dart';

// Free demo tiles from the MapLibre project
MapLibreStyles.demo
// → 'https://demotiles.maplibre.org/style.json'

// OpenFreeMap Liberty style (free, no API key)
MapLibreStyles.openfreemapLiberty
// → 'https://tiles.openfreemap.org/styles/liberty'

Use MapLibreStyles.demo during development. Switch to openfreemapLiberty or your own tile provider for production.

Specifying a style

Pass a style URL (or asset path) to MapLibreMap:

MapLibreMap(
  styleString: MapLibreStyles.openfreemapLiberty,
  initialCameraPosition: const CameraPosition(
    target: LatLng(48.85, 2.35),
    zoom: 12,
  ),
)

Remote URL

Any https:// URL pointing to a valid style JSON:

styleString: 'https://tiles.openfreemap.org/styles/bright'

Local asset

Ship a style JSON in your app bundle. Add it to pubspec.yaml:

flutter:
  assets:
    - assets/my_style.json

Then reference it by asset path:

styleString: 'assets/my_style.json'

This is how PMTiles styles work: the style JSON is local, but it references remote or bundled .pmtiles data. Sprite and glyph URLs inside that JSON are fetched by the native engines, not by Flutter: see Local sprites and glyphs.

File on device

An absolute path to a JSON file the app wrote itself, for example a style downloaded into the documents directory:

styleString: '/data/user/0/com.example.app/app_flutter/my_style.json'

Raw JSON string

You can pass a raw JSON string on any platform:

styleString: '{"version":8,"sources":{},"layers":[]}'

Not recommended for production. Use a file.

Local sprites and glyphs

styleString: 'assets/my_style.json' only loads the style document. The sprite and glyphs URLs inside it are fetched by the native engines, which resolve asset:// against the platform's own asset root: the APK assets on Android, the app bundle root on iOS. Flutter's assets are not there, they live under flutter_assets/ (inside App.framework on iOS), so asset://sprites/sprite and asset://glyphs/{fontstack}/{range}.pbf do not resolve.

Copy those files to a directory on disk instead, then point the style at file:// URLs. That works the same way on both platforms. Android and iOS only; file:// is not a web asset scheme.

Bundle the files

Sprites are a flat set of files, glyphs are one directory per font stack. Flutter asset directories are not recursive, so each glyph directory needs its own entry in pubspec.yaml:

flutter:
  assets:
    - assets/sprites/
    # One entry per font stack directory: `assets/glyphs/` alone bundles nothing.
    - assets/glyphs/Noto Sans Regular/
    - assets/glyphs/Noto Sans Bold/
    - assets/glyphs/Noto Sans Regular,Arial Unicode MS Regular/

sprite in the style is a prefix, not a file: MapLibre appends the extension itself and picks one set based on the screen density, sprite.json and sprite.png at pixel ratio 1, sprite@2x.json and sprite@2x.png above it. Almost every device is above it, so ship both pairs:

assets/sprites/sprite.json
assets/sprites/sprite.png
assets/sprites/sprite@2x.json
assets/sprites/sprite@2x.png

Copy them to disk at startup

Add path_provider to your dependencies, then copy the asset prefixes you need into the cache directory:

import 'dart:io';

import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';

/// Bump this whenever you ship different sprites or glyphs.
const mapAssetsVersion = 1;

Future<String> copyAssetPrefixesToCache(List<String> prefixes) async {
  final cache = await getApplicationCacheDirectory();
  final stamp = File('${cache.path}/maplibre_assets.version');
  if (stamp.existsSync() && stamp.readAsStringSync() == '$mapAssetsVersion') {
    return cache.path;
  }
  final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
  final assets = manifest.listAssets().where(
    (asset) => prefixes.any(asset.startsWith),
  );
  for (final asset in assets) {
    final data = await rootBundle.load(asset);
    final out = File('${cache.path}/$asset');
    await out.parent.create(recursive: true);
    await out.writeAsBytes(
      data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes),
    );
  }
  await stamp.writeAsString('$mapAssetsVersion');
  return cache.path;
}

The copy keeps the asset paths, so assets/sprites/sprite.png lands in <cache>/assets/sprites/sprite.png.

The stamp file keeps later launches cheap: when it matches, nothing is read out of the asset bundle at all. Bump mapAssetsVersion when you ship new sprites or glyphs, otherwise the app keeps serving the copies made by the previous version. The system may purge the cache directory at any time, which is harmless here because the copy runs again at the next launch; use getApplicationSupportDirectory() instead if you want the copies to survive regardless.

Point the style at the copies

final cache = await copyAssetPrefixesToCache([
  'assets/sprites/',
  'assets/glyphs/',
]);

final style = '''
{
  "version": 8,
  "sprite": "file://$cache/assets/sprites/sprite",
  "glyphs": "file://$cache/assets/glyphs/{fontstack}/{range}.pbf",
  "sources": {},
  "layers": []
}
''';

{fontstack} and {range} stay as placeholders; MapLibre fills them in per request. Font stack names containing spaces and commas, such as Noto Sans Regular,Arial Unicode MS Regular, need no escaping: the engine percent-decodes file:// paths before reading them.

Write that JSON next to the copied files and pass its absolute path to the map:

final styleFile = File('$cache/style.json');
await styleFile.writeAsString(style);

MapLibreMap(
  // Absolute path: a relative string is looked up as a Flutter asset instead.
  styleString: styleFile.path,
  initialCameraPosition: const CameraPosition(
    target: LatLng(48.85, 2.35),
    zoom: 12,
  ),
)

Passing the JSON directly as styleString works too, see Raw JSON string.

What goes in styleString, and what does not

file:// belongs in sprite and glyphs, which the native engines fetch themselves. The style document is resolved by the plugin, and it takes exactly four forms:

Where your style is What to pass
A file on disk its absolute path: '$cache/style.json'
Your Flutter assets its asset key: 'assets/my_style.json'
Built in Dart the JSON string itself
A server its URL: 'https://example.com/style.json'

Anything else is read as an asset key, misses, and leaves the map blank without throwing. So 'file:///...' and 'asset://...' never work here: the plugin adds the asset:// prefix itself, and writing it yourself only doubles it.

Switching style at runtime

await controller.setStyle(MapLibreStyles.openfreemapLiberty);

Style reload clears layers

Calling setStyle() removes all sources and layers you added programmatically. Re-add them in onStyleLoadedCallback.

MapLibreMap(
  onStyleLoadedCallback: _onStyleLoaded,
)

Future<void> _onStyleLoaded() async {
  // Re-add your GeoJSON sources and layers here
  await controller.addGeoJsonSource(...);
  await controller.addCircleLayer(...);
}

Custom tile headers

If your tile provider requires authentication headers:

await controller.setCustomHeaders(
  {
    'Authorization': 'Bearer $myToken',
    'X-Api-Key': myApiKey,
  },
  [], // no URL filter: apply to every request
);

Headers are sent with all tile requests from that point forward. The second argument is a list of regular expressions: pass patterns there to restrict the headers to matching URLs. Android and iOS only: the call throws UnimplementedError on web. For headers that apply to every map in the process, use the top-level setHttpHeaders function.

Current style info

final styleJson = await controller.getStyle();
// The full resolved style as a JSON string, or null if the style is not ready.
// Decode it with jsonDecode when you need a map.

To inspect one piece of the style instead of all of it, read a single layer or source by id. Both return the object's properties as a style-spec map, in the same shape on every platform, or null when the id does not exist:

final ids = await controller.getLayerIds();              // what is in the style
final layer = await controller.getLayerProperties('roads');
final source = await controller.getSourceProperties('osm');

if (layer != null) {
  debugPrint('roads is a ${layer['type']} layer');
}

This is useful for reading a value the active style chose before overriding it, and for asserting in tests that your runtime styling landed. null is the answer for an unknown id rather than an error, so it doubles as an existence check.

Attribution

Styles carry attribution for their data, and the map shows it in an (i) button whose position and margins you can set with attributionButtonPosition and attributionButtonMargins. When the SDK's own tint does not read well against a particular style, for example a dark basemap, override just the color:

MapLibreMap(
  attributionButtonColor: Colors.white,
)

Leave it unset to keep the MapLibre default. Android and iOS only: on web that control is HTML and is styled with CSS.

Move it and recolor it, but keep it visible. Nearly every open basemap is built from OpenStreetMap data, published under the ODbL, which requires crediting © OpenStreetMap contributors with a link to openstreetmap.org/copyright; tile providers add their own terms on top. When you add a source yourself with addSource, set its attribution property so the control has something to show.

PMTiles styles

PMTiles is a self-hosted tile format that bundles all tiles into a single .pmtiles file, with no tile server needed. See PMTiles guide for a full walkthrough.

Terms change; this summary is accurate as of August 2026, so check each provider's page before you commit to one. All of them require you to keep their attribution visible.

ProviderFree tierAPI keyStyle URL
OpenFreeMap✔ No published cap✘ Not neededtiles.openfreemap.org/styles/liberty
MapLibre demo● Dev only✘ Not neededdemotiles.maplibre.org/style.json
MapTiler● Free tier✔ Requiredapi.maptiler.com/maps/basic/style.json?key=...
Stadia Maps● Free tier✔ Requiredtiles.stadiamaps.com/styles/alidade_smooth.json?api_key=...
AWS Location● Pay-as-you-go✔ RequiredVia AWS SDK