Short answer

MapLibre GL JS is an open-source WebGL map renderer (the community fork of Mapbox GL JS v1) that draws vector and raster data in the browser from a declarative style. For geological web maps it is a strong default: it renders large vector-tile datasets smoothly, styles features from their attributes through expressions, and supports 3D terrain and hillshade natively. The work that determines success is not the library API — it is the data strategy behind it: tile your data appropriately, drive symbology from real geological attributes, and keep provenance reachable. Pick the wrong delivery format and even a perfect style stutters.

This guide walks through the concrete MapLibre building blocks for a geologic map: sources and tiling, data-driven styling of units and faults, terrain and hillshade, interaction, and performance.

Sources, layers, and the style document

A MapLibre map is configured by a style — a JSON document with two key sections. sources declare where data comes from; layers declare how each source is drawn. The separation matters: one source (your geology vector tiles) can feed several layers (unit fills, contact lines, fault lines, labels), each styled independently.

const map = new maplibregl.Map({
  container: 'map',
  style: {
    version: 8,
    sources: {
      geology: { type: 'vector', url: 'pmtiles://https://cdn.example.org/geology.pmtiles' },
      basemap: { type: 'raster', tiles: ['https://.../{z}/{x}/{y}.png'], tileSize: 256 }
    },
    layers: [
      { id: 'base', type: 'raster', source: 'basemap' }
    ]
  },
  center: [-119.6, 37.7], zoom: 9
});

Source types you will use: vector for tiled geology, geojson for small/editable layers, raster for basemaps and tiled imagery, and raster-dem for elevation. Layer types: fill (units), line (contacts, faults), symbol (labels and point markers), hillshade, and fill-extrusion for extruded blocks.

Vector tiles vs GeoJSON: choosing delivery

The format decision drives performance more than anything else. A single GeoJSON file is loaded whole and held in memory, so it is fine for a few hundred to a few thousand features that change often, but a national geology layer shipped as GeoJSON will freeze the browser. Vector tiles solve this by pre-cutting the data into zoom-and-position tiles so the client fetches only what is on screen at the current zoom, already simplified for that zoom.

Generate vector tiles with tippecanoe, which simplifies geometry per zoom and drops or coalesces tiny features so low zooms stay light:

tippecanoe -o geology.mbtiles \
  -z14 -Z4 \
  --drop-densest-as-needed \
  --coalesce-smallest-as-needed \
  -l geology \
  geology.geojson

Serve the result as PMTiles (a single-file format MapLibre reads directly via the PMTiles plugin, ideal for static hosting) or from a tile server. Reserve GeoJSON for an editable overlay — say, field stations a geologist is still adding.

Data-driven styling of units and faults

The power of MapLibre for geology is that symbology is computed from attributes through expressions, so you encode the data model into the style rather than baking colors into the data. Color map units from their MapUnit key:

map.addLayer({
  id: 'units', type: 'fill', source: 'geology', 'source-layer': 'geology',
  filter: ['==', ['geometry-type'], 'Polygon'],
  paint: {
    'fill-color': ['match', ['get', 'MapUnit'],
      'Qal', '#fffdbf',
      'Tb',  '#d7c19a',
      'Kgr', '#f4a3a3',
      '#cccccc'],          // fallback for unmapped units
    'fill-opacity': 0.7
  }
});

Faults carry geological meaning in their line style. Standard convention distinguishes mapped, approximate, inferred, and concealed faults; encode a confidence attribute and translate it to dash patterns:

map.addLayer({
  id: 'faults', type: 'line', source: 'geology', 'source-layer': 'faults',
  paint: {
    'line-color': '#7a0000',
    'line-width': ['interpolate', ['linear'], ['zoom'], 6, 0.8, 14, 2.5],
    'line-dasharray': ['match', ['get', 'confidence'],
      'mapped', ['literal', [1]],
      'approximate', ['literal', [4, 2]],
      'inferred', ['literal', [1, 2]],
      ['literal', [4, 2]]]
  }
});

The interpolate expression widens lines as the user zooms in, which keeps a fault legible at z6 without becoming a fat ribbon at z14. The same match/step pattern handles age-based coloring, contact types, or any classified attribute.

Terrain and hillshade

Relief is central to geological reading, and MapLibre renders it from a raster-dem source — elevation packed into RGB tiles (Terrarium or Mapbox Terrain-RGB encoding). To make your own from a DEM, encode it with rio-rgbify or tile with GDAL, then:

map.addSource('dem', { type: 'raster-dem', url: 'pmtiles://.../dem.pmtiles', encoding: 'terrarium' });
map.setTerrain({ source: 'dem', exaggeration: 1.4 });   // 3D relief
map.addLayer({ id: 'hillshade', type: 'hillshade', source: 'dem',
  paint: { 'hillshade-shadow-color': '#473b24', 'hillshade-exaggeration': 0.6 } });

A hillshade layer beneath the unit fills (with the fills semi-transparent) is the classic combination that lets structure show through the geology. Use exaggeration cautiously: 1.3–1.6 reads naturally; higher values dramatize relief and can mislead about real slope.

One layer-ordering point trips people up. MapLibre draws layers in the order they appear in the style, so the hillshade must be added before the unit fills for the geology to sit on top of the shaded relief rather than under it. If you add layers dynamically after load, use the second argument of addLayer(layer, beforeId) to insert each one at the right point in the stack rather than always appending to the top. For a typical geologic map the stack from bottom to top is: basemap, hillshade, unit fills, contacts, faults, then labels — so that text never disappears behind a fill and faults remain visible over the units they cut.

Interaction and provenance

Geological web maps live or die on inspection. Wire click-to-query so every feature reveals its attributes and source:

map.on('click', 'units', (e) => {
  const p = e.features[0].properties;
  new maplibregl.Popup()
    .setLngLat(e.lngLat)
    .setHTML(`<b>${p.Name}</b><br>Age: ${p.Age}<br>Source: ${p.DataSourceID}`)
    .addTo(map);
});
map.on('mouseenter', 'units', () => map.getCanvas().style.cursor = 'pointer');

Include the source and date in the popup so a reader can trace any value. Build the legend from the same expression you styled with, so legend and map never disagree, and label modelled layers as modelled.

Performance tuning

  • Tile, don't dump. The single biggest win is serving large layers as vector tiles rather than GeoJSON; see the tippecanoe step above.
  • Limit max zoom in tiles. Generating tiles to z14 when the map only ever shows z11 wastes processing and storage. Match -z to actual use.
  • Simplify per zoom. Let tippecanoe simplify; do not ship full-resolution contacts to z6 where they collapse into noise anyway.
  • Cluster dense points. For thousands of sample points, enable GeoJSON cluster: true so the browser draws aggregates at low zoom.
  • Mind layer count. Hundreds of separate layers strain the renderer; prefer one layer with a data-driven expression over many near-identical layers.

Common pitfalls and why they happen

  • Shipping desktop GeoJSON straight to the browser. It is the path of least resistance from QGIS, and it works on the small test extent — then locks up on the full dataset. Tile first.
  • Baking color into the data. Storing a color field per feature reintroduces duplication and breaks the moment symbology changes; drive color from the unit key with a match expression.
  • Ignoring fault certainty. Drawing every fault solid implies a precision the mapping does not have. Encode confidence into the dash pattern.
  • Over-exaggerated terrain. High exaggeration looks dramatic in a demo but misrepresents slope; keep it near reality for analytical maps.
  • Mismatched legend. A hand-built legend drifts from the style as the style evolves; generate both from the same definition.

Validation

  • The full-extent dataset pans and zooms smoothly on a mid-range device, not just the test area.
  • Unit colors and fault dash patterns are driven by attributes, and the legend matches them exactly.
  • Click-to-query returns each feature's attributes plus source and date.
  • Terrain exaggeration is realistic; hillshade reads beneath semi-transparent units.
  • Modelled or interpolated layers are visually and textually distinguished from observed data.

Bathyl perspective

We treat MapLibre as the rendering tier and put the engineering effort into the data tier beneath it: clean attributes, sensibly tiled vector data, and styles that encode the geological model rather than hard-coded colors. Done that way, the same style document can serve a 1:24,000 sheet and a regional overview, and the geologist's distinctions — unit, age, fault certainty — survive intact into the browser.

Related reading

Sources