UIPackage

Map

Vue data-display
Edit on GitHub

A thin, theme-aware Mapbox GL JS wrapper (built on @studiometa/vue-mapbox-gl). Pass an access token and drop MapMarker / MapPopup / MapLayer into the slot to build any map — fleet boards, journey maps, store locators. The base style follows light/dark automatically, an opt-in `muted` prop desaturates the basemap so overlaid data is the only colour, and `@created` hands you the raw map instance for custom layers and fitBounds.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/map.json

Or with the named registry: npx shadcn-vue@latest add @uipkge/map

Examples

Props

Name Type / Values Default Required
accessToken

Mapbox access token.

string required
mapStyle

Override the base style. Defaults to a theme-aware light/dark style.

string undefined optional
center

Initial [lng, lat].

[number, number] () => [0 optional
zoom number optional
projection

'mercator' (flat) or 'globe'.

string optional
navigation

Show the zoom/compass control.

boolean optional
muted

Repaint the basemap to a quiet, desaturated canvas.

boolean optional

Dependencies

Used by

Files (3)

  • app/components/ui/map/Map.vue 5.5 kB
    <script setup lang="ts">
    /**
     * Map — a thin, theme-aware wrapper around @studiometa/vue-mapbox-gl's
     * MapboxMap. Pass an `access-token`; drop `<MapMarker>` / `<MapPopup>` /
     * `<MapLayer>` (re-exported from this package) or any @studiometa child into
     * the default slot to build any kind of map.
     *
     * - The base style follows the app theme (light-v11 / dark-v11) unless you pass
     *   an explicit `map-style`.
     * - `muted` repaints the basemap to a quiet, desaturated canvas (graphite in
     *   dark, light-grey in light) so overlaid data is the only colour on the map —
     *   ideal for dashboards, fleet boards and journey maps.
     * - `@created` hands you the raw mapbox-gl Map instance for imperative work
     *   (custom layers, fitBounds, controls).
     *
     * Requires a Mapbox access token (https://account.mapbox.com).
     */
    import { computed, onMounted, ref } from 'vue'
    import mapboxgl from 'mapbox-gl'
    import { MapboxMap } from '@studiometa/vue-mapbox-gl'
    import 'mapbox-gl/dist/mapbox-gl.css'
    
    const props = withDefaults(
      defineProps<{
        /** Mapbox access token. */
        accessToken: string
        /** Override the base style. Defaults to a theme-aware light/dark style. */
        mapStyle?: string
        /** Initial [lng, lat]. */
        center?: [number, number]
        zoom?: number
        /** 'mercator' (flat) or 'globe'. */
        projection?: string
        /** Show the zoom/compass control. */
        navigation?: boolean
        /** Repaint the basemap to a quiet, desaturated canvas. */
        muted?: boolean
      }>(),
      {
        mapStyle: undefined,
        center: () => [0, 20],
        zoom: 1.4,
        projection: 'mercator',
        navigation: true,
        muted: false,
      },
    )
    
    const emit = defineEmits<{ (e: 'created', map: mapboxgl.Map): void }>()
    
    const { theme } = useTheme()
    const isDark = () =>
      theme.value === 'dark' ||
      (theme.value === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches)
    
    const resolvedStyle = computed(
      () => props.mapStyle ?? (isDark() ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/light-v11'),
    )
    
    // Render the map only on the client — Mapbox needs the DOM, and rendering it on
    // the server would hydrate-mismatch. The bg-muted box is the SSR placeholder.
    const mounted = ref(false)
    onMounted(() => {
      mounted.value = true
    })
    
    let map: mapboxgl.Map | null = null
    
    /** Desaturate the basemap to a quiet canvas. Each op is guarded — style layer
     *  ids drift between style versions and the wrong property for a layer type
     *  throws. */
    function applyMuted() {
      if (!map) return
      let styleLayers: any[]
      try {
        styleLayers = map.getStyle()?.layers ?? []
      } catch {
        return // style not loaded yet
      }
      const P = isDark()
        ? { land: 'rgb(23,24,29)', water: 'rgb(17,18,22)', use: 'rgb(31,32,38)', road: 'rgb(50,52,60)', label: 'rgb(150,152,165)', halo: 'rgb(23,24,29)', admin: 'rgb(50,52,60)' }
        : { land: 'rgb(246,247,249)', water: 'rgb(226,230,235)', use: 'rgb(238,240,243)', road: 'rgb(221,224,229)', label: 'rgb(120,124,134)', halo: 'rgb(246,247,249)', admin: 'rgb(213,216,221)' }
      for (const l of styleLayers) {
        const id = l.id
        try {
          if (id === 'background' || id === 'land') map.setPaintProperty(id, 'background-color', P.land)
          else if (/water/.test(id) && l.type === 'fill') map.setPaintProperty(id, 'fill-color', P.water)
          else if (/landuse|landcover|national-park/.test(id) && l.type === 'fill') map.setPaintProperty(id, 'fill-color', P.use)
          else if (/^road-(motorway|trunk|primary)/.test(id) && l.type === 'line') {
            map.setPaintProperty(id, 'line-color', P.road)
            map.setPaintProperty(id, 'line-opacity', 0.55)
          } else if (/^road-(secondary|tertiary|street|minor|service|path|pedestrian)/.test(id)) {
            map.setLayoutProperty(id, 'visibility', 'none')
          } else if (/poi|transit|airport|natural-point|water-point|waterway-label|building/.test(id)) {
            map.setLayoutProperty(id, 'visibility', 'none')
          } else if (/road-label|settlement-major-label|settlement-minor-label|state-label/.test(id) && l.type === 'symbol') {
            map.setPaintProperty(id, 'text-color', P.label)
            map.setPaintProperty(id, 'text-halo-color', P.halo)
            map.setPaintProperty(id, 'text-opacity', 0.6)
          } else if (id === 'admin-1-boundary' && l.type === 'line') {
            map.setPaintProperty(id, 'line-color', P.admin)
            map.setPaintProperty(id, 'line-opacity', 0.6)
          } else if (id === 'admin-1-boundary-bg') map.setPaintProperty(id, 'line-opacity', 0)
        } catch {
          /* skip */
        }
      }
    }
    
    function onCreated(instance: mapboxgl.Map) {
      map = instance
      try {
        ;(map as any).setProjection(props.projection)
      } catch {
        /* older api */
      }
      if (props.navigation) map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'bottom-right')
      // Emit once the style has loaded so the handler can safely add sources/layers.
      // (A theme restyle wipes custom layers — re-add them in your own
      // `map.on('style.load')` if you need them to survive a theme switch.)
      const ready = () => {
        if (props.muted) applyMuted()
        emit('created', map!)
      }
      if (map.isStyleLoaded()) ready()
      else map.once('load', ready)
      map.on('style.load', () => {
        if (props.muted) applyMuted()
      })
    }
    </script>
    
    <template>
      <div class="bg-muted size-full overflow-hidden">
        <MapboxMap
          v-if="mounted"
          class="size-full"
          :access-token="accessToken"
          :map-style="resolvedStyle"
          :center="center"
          :zoom="zoom"
          :attribution-control="false"
          @mb-created="onCreated"
        >
          <slot />
        </MapboxMap>
      </div>
    </template>
  • app/components/ui/map/index.ts 0.5 kB
    export { default as Map } from './Map.vue'
    
    // Re-exported from @studiometa/vue-mapbox-gl under Map* names so consumers get
    // the whole map toolkit from one import. Place these inside <Map>'s slot — they
    // inject the map instance from the wrapping MapboxMap.
    export {
      MapboxMarker as MapMarker,
      MapboxPopup as MapPopup,
      MapboxLayer as MapLayer,
      MapboxSource as MapSource,
      MapboxNavigationControl as MapNavigationControl,
    } from '@studiometa/vue-mapbox-gl'
  • app/components/ui/map/map.css 1.4 kB
    /**
     * Map chrome — re-skins Mapbox GL's controls + popups to the design-system
     * tokens so they sit naturally on light and dark surfaces. Global (Mapbox
     * renders this chrome outside the component subtree). Import once, e.g. in your
     * root CSS: `@import './components/ui/map/map.css';`
     */
    .mapboxgl-ctrl-group {
      background: var(--card);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      box-shadow: var(--shadow-sm, 0 1px 2px rgb(0 0 0 / 0.08));
    }
    .mapboxgl-ctrl-group button {
      background: transparent;
    }
    .mapboxgl-ctrl-group button + button {
      border-top-color: var(--border);
    }
    .mapboxgl-ctrl-group button .mapboxgl-ctrl-icon {
      filter: invert(var(--map-ctrl-invert, 0)) opacity(0.7);
    }
    .dark .mapboxgl-ctrl-group button .mapboxgl-ctrl-icon {
      filter: invert(1) opacity(0.7);
    }
    .mapboxgl-ctrl-attrib {
      background: color-mix(in oklab, var(--card) 80%, transparent);
      color: var(--muted-foreground);
    }
    .mapboxgl-ctrl-attrib a {
      color: var(--muted-foreground);
    }
    .mapboxgl-popup-content {
      padding: 8px 12px;
      background: var(--popover);
      color: var(--popover-foreground);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      box-shadow: var(--shadow-md, 0 4px 12px rgb(0 0 0 / 0.12));
    }
    .mapboxgl-popup-close-button {
      color: var(--muted-foreground);
    }
    .mapboxgl-popup-tip {
      border-top-color: var(--popover);
      border-bottom-color: var(--popover);
    }

Raw manifest: https://uipkge.dev/r/vue/map.json