📖 Documentation · 🎮 Live Demo · 🐛 Issues
TypeScript offline-map library for MapLibre GL JS and Mapbox GL JS. Download styles, tiles, sprites, glyphs, and fonts to IndexedDB; load them back with zero network. Ships with a glassmorphic UI control and a complete programmatic API.
- 🗺️ Offline regions — polygon selection, smart tile management, extra vector/raster overlays
- 🎨 Full resource capture — styles, sprites, fonts, glyphs with Unicode ranges
- 🔗 Mapbox GL + MapLibre GL — auto-detection,
mapbox://URL resolution, Standard style with 3D/terrain - 📊 Analytics & cleanup — storage reports, auto-cleanup, quota-aware downloads
- 🎨 UI control — glassmorphic panel, dark/light themes, English/Arabic (RTL), polygon drawing
- 🛠️ Programmatic API —
downloadRegionwith per-phase progress, full TypeScript types
npm install map-gl-offlineOr via CDN as the mapgloffline global:
<script src="https://unpkg.com/map-gl-offline/dist/index.umd.js"></script>
<link rel="stylesheet" href="https://unpkg.com/map-gl-offline/style.css" />import maplibregl from 'maplibre-gl';
import { OfflineMapManager, OfflineManagerControl } from 'map-gl-offline';
import 'maplibre-gl/dist/maplibre-gl.css';
import 'map-gl-offline/style.css';
const map = new maplibregl.Map({
container: 'map',
style: 'https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY',
center: [-74.006, 40.7128],
zoom: 12,
});
const offlineManager = new OfflineMapManager();
map.on('load', () => {
const control = new OfflineManagerControl(offlineManager, {
styleUrl: 'https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY',
mapLib: maplibregl, // enables idb:// protocol in web workers
});
map.addControl(control, 'top-right');
});Mapbox GL JS v3 lacks addProtocol, so the library uses a Service Worker. Run one of:
npx map-gl-offline init # CLI (recommended)
# or add to vite.config.js:
# import { offlineSwPlugin } from 'map-gl-offline/vite-plugin';
# plugins: [offlineSwPlugin()]
# or manually: cp node_modules/map-gl-offline/dist/idb-offline-sw.js public/Then:
import mapboxgl from 'mapbox-gl';
import { OfflineMapManager, OfflineManagerControl } from 'map-gl-offline';
import 'mapbox-gl/dist/mapbox-gl.css';
import 'map-gl-offline/style.css';
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/standard',
center: [-74.006, 40.7128],
zoom: 12,
});
const offlineManager = new OfflineMapManager();
map.on('load', () =>
map.addControl(
new OfflineManagerControl(offlineManager, {
styleUrl: 'mapbox://styles/mapbox/standard',
accessToken: mapboxgl.accessToken,
}),
'top-right',
),
);downloadRegion runs the full pipeline (style → sprites → glyphs → models → tiles → metadata) with per-phase progress. addRegion alone only stores metadata — use downloadRegion to actually fetch assets.
import { OfflineMapManager } from 'map-gl-offline';
const offlineManager = new OfflineMapManager();
await offlineManager.downloadRegion(
{
id: 'downtown',
name: 'Downtown',
bounds: [[-74.0559, 40.7128], [-74.0059, 40.7628]],
minZoom: 10,
maxZoom: 16,
styleUrl: 'https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY',
},
{
onProgress: ({ phase, percentage, message }) => {
console.log(`[${phase}] ${percentage.toFixed(1)}% ${message ?? ''}`);
},
},
);
// Manage
await offlineManager.listStoredRegions();
await offlineManager.getStoredRegion('downtown');
await offlineManager.deleteRegion('downtown');
// Analytics & cleanup
await offlineManager.getComprehensiveStorageAnalytics();
await offlineManager.cleanupExpiredRegions();
await offlineManager.setupAutoCleanup({ intervalHours: 24, maxAge: 30 });For app shipping use cases — a low-zoom basemap of the world, plus high-zoom detail in the cities your users actually visit — set multipleRegions: true on each region so the manager reuses the shared style/sprites/glyphs across downloads. The exported BoundingBox type keeps city lists from being widened to number[][], so you can write the bounds inline without as casts.
import mapboxgl from 'mapbox-gl';
import {
OfflineMapManager,
type BoundingBox,
type DownloadRegionProgress,
} from 'map-gl-offline';
const offlineManager = new OfflineMapManager();
const STYLE_URL = 'mapbox://styles/mapbox/standard';
const opts = {
accessToken: mapboxgl.accessToken, // `string | null` is accepted — no cast needed
onProgress: ({ phase, percentage, message }: DownloadRegionProgress) =>
console.log(`[${phase}] ${percentage.toFixed(1)}% ${message ?? ''}`),
};
// 1) Whole planet, low zoom only (~5,500 tiles/source) — countries, major cities
await offlineManager.downloadRegion(
{
id: 'global-overview',
name: 'Global overview',
bounds: [[-180, -85.0511], [180, 85.0511]], // ±85.0511° = Web Mercator cutoff
minZoom: 0,
maxZoom: 6,
styleUrl: STYLE_URL,
multipleRegions: true,
},
opts,
);
// 2) High-detail per city — tight bbox per place your users actually go
const cities: Array<{ id: string; name: string; bounds: BoundingBox }> = [
{ id: 'nyc', name: 'New York', bounds: [[-74.05, 40.68], [-73.90, 40.82]] },
{ id: 'london', name: 'London', bounds: [[-0.25, 51.43], [0.02, 51.57]] },
];
for (const city of cities) {
await offlineManager.downloadRegion(
{
id: city.id,
name: city.name,
bounds: city.bounds,
minZoom: 6, // overlaps the overview's maxZoom — clean handoff, no seam
maxZoom: 14, // street-level detail
styleUrl: STYLE_URL,
multipleRegions: true,
},
opts,
);
}Don't download the whole globe at high zoom. The tile count quadruples per zoom level —
minZoom: 0, maxZoom: 15for the whole planet is ~1.4 billion tiles per source, which will blow past IndexedDB quota and may violate provider TOS. The two-tier setup above gives countries-everywhere plus streets-where-it-matters for a few thousand tiles total.
For composite styles (e.g. Mapbox Standard) that reference sparse tilesets like indoor-v3 or landmark-pois-v1, the tile downloader probes start/middle/end tiles per source and drops any that return majority-404. Disable with tileOptions: { probeSourcesBeforeDownload: false }.
Three Mapbox Standard sub-tilesets are sparse-by-design across the planet — mapbox.indoor-v3, mapbox.landmark-pois-v1, mapbox.procedural-buildings-v1. Since 0.8.8 these are hard-skipped before the probe step so no network request is issued (and no 404s land in devtools). Opt out with tileOptions: { skipKnownSparseSources: false } to run them through the probe path instead.
If another app on the same origin has created offline-map-db at a newer version, dbPromise throws a typed error. Offer a reset UX:
import { dbPromise, OfflineMapDBVersionError, resetOfflineMapDB } from 'map-gl-offline';
try {
await dbPromise;
} catch (err) {
if (err instanceof OfflineMapDBVersionError) {
if (confirm('Offline storage is incompatible. Clear it?')) {
await resetOfflineMapDB(); // destructive
location.reload();
}
}
}Upgrading from 0.5.x? Read the 0.6.0 migration guide — covers the rename of
ResourceService.getXxxStatistics→getXxxStats, theaddRegionvsdownloadRegionsplit, and theexpirytimestamp fix.
- Regions —
downloadRegion,loadRegion,addRegion,getStoredRegion,listStoredRegions,listRegions,deleteRegion - Analytics —
getComprehensiveStorageAnalytics,getRegionAnalytics,getTileStats,getFontStats,getSpriteStats,getGlyphStats - Cleanup —
cleanupExpiredRegions,performSmartCleanup,cleanupOld{Fonts,Sprites,Glyphs},verifyAndRepair{Fonts,Sprites,Glyphs},setupAutoCleanup,performCompleteMaintenance - Import / Export —
exportRegionAsMBTiles,importRegion,downloadExportedRegion(binary SQLite MBTiles, QGIS/tippecanoe-compatible) - Storage utilities —
dbPromise,OfflineMapDBVersionError,resetOfflineMapDB,loadAllStoredRegions,resourceKeyBelongsToStyle
See the full API reference and examples for every option and pattern.
Chrome 51+ · Firefox 45+ · Safari 10+ · Edge 79+ · modern mobile browsers. Requires IndexedDB and ES2015+.
git clone https://github.com/muimsd/map-gl-offline.git
cd map-gl-offline && npm install
npm run dev # dev harness
npm test # unit tests
npm run build # libraryIssues and PRs welcome. See CHANGELOG.md for release notes.
MIT © Muhammad Imran Siddique

