Skip to content
Hillshade Strength Slider

Control Hillshade Strength with a Slider in Maptoolkit Maps JS

Relief shading is the clearest case for giving readers a dial. Strong hillshading makes a mountain map readable and makes a city map muddy, and the right amount depends on the terrain, the zoom and who is looking. Rather than picking one value, hand it over: a slider for strength and a checkbox to switch it off. The same two controls work on any overlay, and the mechanics differ by layer type in one way that catches people out.

const API_KEY = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [11.39085, 47.27574],
        zoom: 11,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    map.on('load', () => {
        map.addSource('terrain-dem', {
            type: 'raster-dem',
            tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
            tileSize: 256,
            minzoom: 5,
            maxzoom: 12,
            encoding: 'terrarium'
        });

        const firstSymbolId = map.getStyle().layers.find((l) => l.type === 'symbol')?.id;

        map.addLayer({
            id: 'hillshade',
            type: 'hillshade',
            source: 'terrain-dem',
            paint: {
                'hillshade-exaggeration': 0.6,
                'hillshade-shadow-color': '#4a3b2a',
                'hillshade-highlight-color': '#ffffff'
            }
        }, firstSymbolId);

        const slider = document.getElementById('opacity');
        const toggle = document.getElementById('visible');
        const readout = document.getElementById('pct');

        slider.addEventListener('input', () => {
            const pct = Number(slider.value);
            readout.textContent = `${pct}%`;
            // Paint properties are the opacity dial. Each layer type names its own.
            map.setPaintProperty('hillshade', 'hillshade-exaggeration', pct / 100);
        });

        toggle.addEventListener('change', () => {
            // Visibility is a LAYOUT property, and the values are strings.
            map.setLayoutProperty('hillshade', 'visibility', toggle.checked ? 'visible' : 'none');
            slider.disabled = !toggle.checked;
        });
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Opacity Slider and Toggle - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Fade and toggle an overlay layer." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        #controls {
            position: absolute; top: 10px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 13px/1.6 system-ui, sans-serif; padding: 10px 12px; width: 190px;
        }
        #controls label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
        #controls input[type=range] { width: 100%; margin: 6px 0 0; }
        #controls .value { margin-left: auto; color: #777; font-variant-numeric: tabular-nums; }
    </style>
</head>
<body>
<div id="map"></div>
<div id="controls">
    <label><input type="checkbox" id="visible" checked> Hillshade <span class="value" id="pct">60%</span></label>
    <input type="range" id="opacity" min="0" max="100" value="60">
</div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: [11.39085, 47.27574],
        zoom: 11,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl(), 'top-right');

    map.on('load', () => {
        map.addSource('terrain-dem', {
            type: 'raster-dem',
            tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
            tileSize: 256,
            minzoom: 5,
            maxzoom: 12,
            encoding: 'terrarium'
        });

        const firstSymbolId = map.getStyle().layers.find((l) => l.type === 'symbol')?.id;

        map.addLayer({
            id: 'hillshade',
            type: 'hillshade',
            source: 'terrain-dem',
            paint: {
                'hillshade-exaggeration': 0.6,
                'hillshade-shadow-color': '#4a3b2a',
                'hillshade-highlight-color': '#ffffff'
            }
        }, firstSymbolId);

        const slider = document.getElementById('opacity');
        const toggle = document.getElementById('visible');
        const readout = document.getElementById('pct');

        slider.addEventListener('input', () => {
            const pct = Number(slider.value);
            readout.textContent = `${pct}%`;
            // Paint properties are the opacity dial. Each layer type names its own.
            map.setPaintProperty('hillshade', 'hillshade-exaggeration', pct / 100);
        });

        toggle.addEventListener('change', () => {
            // Visibility is a LAYOUT property, and the values are strings.
            map.setLayoutProperty('hillshade', 'visibility', toggle.checked ? 'visible' : 'none');
            slider.disabled = !toggle.checked;
        });
    });
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map with a hillshade overlay, an opacity slider that fades it, and a checkbox that toggles its visibility.

How it works

The two controls use different halves of the style spec, and mixing them up is the usual stumbling block.

Opacity is a paint property, set with setPaintProperty. Visibility is a layout property, set with setLayoutProperty, and its values are the strings 'visible' and 'none' rather than booleans. Passing true or false does nothing and reports nothing.

There is no single opacity property across layer types. Each one names its own: fill-opacity, line-opacity, raster-opacity, circle-opacity, icon-opacity, text-opacity. A hillshade layer has none of them, which is why this example drives hillshade-exaggeration instead: on a hillshade, exaggeration is the dial that reads as strength. For a raster overlay the equivalent line is map.setPaintProperty('overlay', 'raster-opacity', pct / 100).

visibility: 'none' stops the layer rendering but does not stop its source fetching tiles, because the source is shared and the style still references it. For a heavy overlay that should genuinely stop costing anything, remove the layer and the source rather than hiding it, and add them back on demand.

Disabling the slider when the layer is hidden is a small thing that prevents the confusing state where someone drags a slider and nothing moves.

The input event fires continuously while dragging, which is what you want here: paint property updates are cheap and the feedback should be immediate. Reach for change instead when the handler does something expensive, such as a network request.

Next steps

Two overlays make the pattern a comparison tool, which is where it earns its place: a slider per layer, or a single slider that cross-fades between them by driving one opacity up as the other goes down.

The same controls suit anything laid over the basemap, including a raster tile source and a WMS overlay, where fading matters more because those services are slower and often the least readable thing on the page.