UIPackage

Map

React data-display
Edit on GitHub

A thin, theme-aware Mapbox GL JS wrapper (built on react-map-gl). Pass an access token and drop MapMarker / MapPopup / MapLayer into the children 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 `onCreated` hands you the raw map instance for custom layers and fitBounds. Import `mapbox-gl/dist/mapbox-gl.css` once in your app.

Also available for Vue ->

Installation

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

Or with the named registry: npx shadcn@latest add @uipkge-react/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 optional
center

Initial [lng, lat].

[number, number] 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
onCreated

Hands you the raw mapbox-gl Map instance once the style has loaded.

(map: mapboxgl.Map) => void optional

Dependencies

Used by

Files (3)

  • components/ui/map/map.tsx 6.7 kB
    'use client'
    
    /**
     * Map — a thin, theme-aware wrapper around react-map-gl's Mapbox `Map`. Pass an
     * `accessToken`; drop `<MapMarker>` / `<MapPopup>` / `<MapLayer>` (re-exported
     * from this package) or any react-map-gl child into the children to build any
     * kind of map.
     *
     * - The base style follows the app theme (light-v11 / dark-v11) unless you pass
     *   an explicit `mapStyle`.
     * - `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.
     * - `onCreated` hands you the raw mapbox-gl Map instance for imperative work
     *   (custom layers, fitBounds, controls).
     *
     * Requires a Mapbox access token (https://account.mapbox.com).
     *
     * IMPORTANT: consumers must import Mapbox GL's stylesheet once (e.g. in their
     * root layout / app entry):  import 'mapbox-gl/dist/mapbox-gl.css'
     * It's intentionally not imported here so bundlers that can't resolve CSS from a
     * library file (some RSC/SSR setups) don't choke — own the import in your app.
     */
    import * as React from 'react'
    import type mapboxgl from 'mapbox-gl'
    import Map, {
      NavigationControl as MapboxNavigationControl,
      type MapRef,
      type MapProps as MapboxMapProps,
    } from 'react-map-gl/mapbox'
    import { useTheme } from '@/lib/use-theme'
    import { cn } from '@/lib/utils'
    
    export interface MapProps extends React.HTMLAttributes<HTMLDivElement> {
      /** 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
      /** Hands you the raw mapbox-gl Map instance once the style has loaded. */
      onCreated?: (map: mapboxgl.Map) => void
    }
    
    /** 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(map: mapboxgl.Map, dark: boolean) {
      let styleLayers: any[]
      try {
        styleLayers = map.getStyle()?.layers ?? []
      } catch {
        return // style not loaded yet
      }
      const P = dark
        ? { 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 */
        }
      }
    }
    
    const MapComponent = React.forwardRef<HTMLDivElement, MapProps>(
      (
        {
          className,
          accessToken,
          mapStyle,
          center = [0, 20],
          zoom = 1.4,
          projection = 'mercator',
          navigation = true,
          muted = false,
          onCreated,
          children,
          ...props
        },
        ref,
      ) => {
        const { resolvedTheme } = useTheme()
        const isDark = resolvedTheme === 'dark'
    
        const resolvedStyle = 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, setMounted] = React.useState(false)
        React.useEffect(() => {
          setMounted(true)
        }, [])
    
        const mapRef = React.useRef<MapRef | null>(null)
    
        const onLoad = React.useCallback(() => {
          const map = mapRef.current?.getMap()
          if (!map) return
          try {
            ;(map as any).setProjection(projection)
          } catch {
            /* older api */
          }
          if (muted) applyMuted(map, isDark)
          onCreated?.(map)
        }, [projection, muted, isDark, onCreated])
    
        const onStyleData = React.useCallback(() => {
          const map = mapRef.current?.getMap()
          if (map && muted) applyMuted(map, isDark)
        }, [muted, isDark])
    
        return (
          <div
            ref={ref}
            data-uipkge=""
            data-slot="map"
            className={cn('bg-muted size-full overflow-hidden', className)}
            {...props}
          >
            {mounted && (
              <Map
                ref={mapRef}
                mapboxAccessToken={accessToken}
                mapStyle={resolvedStyle}
                initialViewState={{ longitude: center[0], latitude: center[1], zoom }}
                attributionControl={false}
                style={{ width: '100%', height: '100%' }}
                onLoad={onLoad}
                onStyleData={onStyleData}
              >
                {navigation && <MapboxNavigationControl position="bottom-right" showCompass={false} />}
                {children}
              </Map>
            )}
          </div>
        )
      },
    )
    MapComponent.displayName = 'Map'
    
    export { MapComponent as Map }
    export type { MapboxMapProps }
    
    // Re-exported here too (not just index.ts) so the toolkit resolves whether a
    // consumer imports from the package dir or the file (shadcn rewrites to the
    // file). Place these inside <Map>'s children.
    export {
      Marker as MapMarker,
      Popup as MapPopup,
      Layer as MapLayer,
      Source as MapSource,
      NavigationControl as MapNavigationControl,
    } from 'react-map-gl/mapbox'
  • components/ui/map/index.ts 0.4 kB
    export { Map } from './map'
    export type { MapProps } from './map'
    
    // Re-exported from react-map-gl under Map* names so consumers get the whole map
    // toolkit from one import. Place these inside <Map>'s children — they read the
    // map instance from the wrapping react-map-gl context.
    export {
      Marker as MapMarker,
      Popup as MapPopup,
      Layer as MapLayer,
      Source as MapSource,
      NavigationControl as MapNavigationControl,
    } from 'react-map-gl/mapbox'
  • 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://react.uipkge.dev/r/react/map.json