Map

Interactive map component with markers, popups, tooltips, routes, and clustering built on MapLibre GL

Installation

pnpm dlx shadcn@latest add "https://vritti.thesatyajit.com/r/map"

Dependencies

pnpm add maplibre-gl lucide-react

Examples

3d

"use client";

import { useEffect, useState } from "react";
import { Map, useMap } from "@/components/vritti/map";
import { RotateCcw, Mountain } from "lucide-react";

function MapController() {
  const { map, isLoaded } = useMap();
  const [pitch, setPitch] = useState(0);
  const [bearing, setBearing] = useState(0);

  useEffect(() => {
    if (!map || !isLoaded) return;

    const handleMove = () => {
      setPitch(Math.round(map.getPitch()));
      setBearing(Math.round(map.getBearing()));
    };

    map.on("move", handleMove);
    return () => {
      map.off("move", handleMove);
    };
  }, [map, isLoaded]);

  const handle3DView = () => {
    map?.easeTo({
      pitch: 60,
      bearing: -20,
      duration: 1000,
    });
  };

  const handleReset = () => {
    map?.easeTo({
      pitch: 0,
      bearing: 0,
      duration: 1000,
    });
  };

  if (!isLoaded) return null;

  return (
    <div className="absolute top-3 left-3 z-10 flex flex-col gap-2">
      <div className="flex gap-2">
        <button
          onClick={handle3DView}
          className="inline-flex items-center rounded-md bg-secondary text-secondary-foreground px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-secondary/80"
        >
          <Mountain className="size-4 mr-1.5" />
          3D View
        </button>
        <button
          onClick={handleReset}
          className="inline-flex items-center rounded-md bg-secondary text-secondary-foreground px-3 py-1.5 text-sm font-medium shadow-sm hover:bg-secondary/80"
        >
          <RotateCcw className="size-4 mr-1.5" />
          Reset
        </button>
      </div>
      <div className="rounded-md bg-background/90 backdrop-blur px-3 py-2 text-xs font-mono border">
        <div>Pitch: {pitch}°</div>
        <div>Bearing: {bearing}°</div>
      </div>
    </div>
  );
}

export function Map3DExample() {
  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[-73.9857, 40.7484]} zoom={15}>
        <MapController />
      </Map>
    </div>
  );
}

Cluster

"use client";

import { useState } from "react";
import { Map, MapClusterLayer, MapPopup, MapControls } from "@/components/vritti/map";

interface EarthquakeProperties {
  mag: number;
  place: string;
  tsunami: number;
}

export function MapClusterExample() {
  const [selectedPoint, setSelectedPoint] = useState<{
    coordinates: [number, number];
    properties: EarthquakeProperties;
  } | null>(null);

  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[-103.59, 40.66]} zoom={3.4} fadeDuration={0}>
        <MapClusterLayer<EarthquakeProperties>
          data="https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson"
          clusterRadius={50}
          clusterMaxZoom={14}
          clusterColors={["#1d8cf8", "#6d5dfc", "#e23670"]}
          pointColor="#1d8cf8"
          onPointClick={(feature, coordinates) => {
            setSelectedPoint({
              coordinates,
              properties: feature.properties,
            });
          }}
        />

        {selectedPoint && (
          <MapPopup
            key={`${selectedPoint.coordinates[0]}-${selectedPoint.coordinates[1]}`}
            longitude={selectedPoint.coordinates[0]}
            latitude={selectedPoint.coordinates[1]}
            onClose={() => setSelectedPoint(null)}
            closeOnClick={false}
            focusAfterOpen={false}
            closeButton
          >
            <div className="space-y-1 p-1">
              <p className="text-sm">
                Magnitude: {selectedPoint.properties.mag}
              </p>
              <p className="text-sm">
                Tsunami:{" "}
                {selectedPoint.properties?.tsunami === 1 ? "Yes" : "No"}
              </p>
            </div>
          </MapPopup>
        )}

        <MapControls />
      </Map>
    </div>
  );
}

Controlled

"use client";

import { useState } from "react";
import { Map, type MapViewport } from "@/components/vritti/map";

export function MapControlledExample() {
  const [viewport, setViewport] = useState<MapViewport>({
    center: [-74.006, 40.7128],
    zoom: 8,
    bearing: 0,
    pitch: 0,
  });

  return (
    <div className="h-[400px] w-full relative overflow-hidden rounded-lg border">
      <Map viewport={viewport} onViewportChange={setViewport} />
      <div className="absolute top-2 left-2 z-10 flex flex-wrap gap-x-3 gap-y-1 text-xs font-mono bg-background/80 backdrop-blur px-2 py-1.5 rounded border">
        <span>
          <span className="text-muted-foreground">lng:</span>{" "}
          {viewport.center[0].toFixed(3)}
        </span>
        <span>
          <span className="text-muted-foreground">lat:</span>{" "}
          {viewport.center[1].toFixed(3)}
        </span>
        <span>
          <span className="text-muted-foreground">zoom:</span>{" "}
          {viewport.zoom.toFixed(1)}
        </span>
        <span>
          <span className="text-muted-foreground">bearing:</span>{" "}
          {viewport.bearing.toFixed(1)}°
        </span>
        <span>
          <span className="text-muted-foreground">pitch:</span>{" "}
          {viewport.pitch.toFixed(1)}°
        </span>
      </div>
    </div>
  );
}

Controls

import { Map, MapControls } from "@/components/vritti/map";

export function MapControlsExample() {
  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[2.3522, 48.8566]} zoom={11}>
        <MapControls
          position="bottom-right"
          showZoom
          showCompass
          showLocate
          showFullscreen
        />
      </Map>
    </div>
  );
}

Delivery

"use client";

import { useEffect, useState } from "react";
import {
  Map,
  MapMarker,
  MarkerContent,
  MarkerLabel,
  MarkerTooltip,
  MapRoute,
  MapControls,
} from "@/components/vritti/map";
import { Truck } from "lucide-react";

const store = { lng: -0.14, lat: 51.5154 };
const home = { lng: -0.07, lat: 51.51 };

export function MapDeliveryExample() {
  const [route, setRoute] = useState<[number, number][]>([]);
  const [truckPosition, setTruckPosition] = useState<[number, number] | null>(
    null
  );

  useEffect(() => {
    async function fetchRoute() {
      try {
        const response = await fetch(
          `https://router.project-osrm.org/route/v1/driving/${store.lng},${store.lat};${home.lng},${home.lat}?overview=full&geometries=geojson`
        );
        const data = await response.json();

        if (data.routes?.[0]?.geometry?.coordinates) {
          const coords: [number, number][] =
            data.routes[0].geometry.coordinates;
          setRoute(coords);
          const truckIdx = Math.floor(coords.length * 0.6);
          setTruckPosition(coords[truckIdx]);
        }
      } catch (error) {
        console.error("Failed to fetch route:", error);
      }
    }

    fetchRoute();
  }, []);

  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[-0.105, 51.511]} zoom={12.4}>
        {route.length > 0 && (
          <MapRoute coordinates={route} width={4} color="#4285F4" />
        )}
        <MapMarker longitude={store.lng} latitude={store.lat}>
          <MarkerContent>
            <div className="size-3.5 rounded-full bg-emerald-500 border-2 border-white shadow-lg" />
            <MarkerLabel>Store</MarkerLabel>
          </MarkerContent>
        </MapMarker>
        {truckPosition && (
          <MapMarker longitude={truckPosition[0]} latitude={truckPosition[1]}>
            <MarkerContent>
              <div className="bg-blue-500 rounded-full p-1.5 shadow-lg">
                <Truck className="size-3 text-white" />
              </div>
            </MarkerContent>
            <MarkerTooltip>On the way</MarkerTooltip>
          </MapMarker>
        )}
        <MapMarker longitude={home.lng} latitude={home.lat}>
          <MarkerContent>
            <div className="size-3.5 rounded-full bg-blue-500 border-2 border-white shadow-lg" />
            <MarkerLabel>Home</MarkerLabel>
          </MarkerContent>
        </MapMarker>
        <MapControls showZoom />
      </Map>
    </div>
  );
}

Draggable

"use client";

import { useState } from "react";
import { Map, MapMarker, MarkerContent, MarkerPopup } from "@/components/vritti/map";
import { MapPin } from "lucide-react";

export function MapDraggableExample() {
  const [draggableMarker, setDraggableMarker] = useState({
    lng: -73.98,
    lat: 40.75,
  });

  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[-73.98, 40.75]} zoom={12}>
        <MapMarker
          draggable
          longitude={draggableMarker.lng}
          latitude={draggableMarker.lat}
          onDragEnd={(lngLat) => {
            setDraggableMarker({ lng: lngLat.lng, lat: lngLat.lat });
          }}
        >
          <MarkerContent>
            <div className="cursor-move">
              <MapPin
                className="fill-black stroke-white dark:fill-white"
                size={28}
              />
            </div>
          </MarkerContent>
          <MarkerPopup>
            <div className="space-y-1">
              <p className="font-medium text-foreground">Coordinates</p>
              <p className="text-xs text-muted-foreground">
                {draggableMarker.lat.toFixed(4)},{" "}
                {draggableMarker.lng.toFixed(4)}
              </p>
            </div>
          </MarkerPopup>
        </MapMarker>
      </Map>
    </div>
  );
}

Globe

"use client";

import {
  Map,
  MapMarker,
  MarkerContent,
  MarkerTooltip,
  MapControls,
} from "@/components/vritti/map";
import { TrendingUp } from "lucide-react";

const analyticsData = [
  { lng: -74.006, lat: 40.7128, city: "New York", users: 847, size: 14 },
  { lng: -0.1276, lat: 51.5074, city: "London", users: 623, size: 12 },
  { lng: 139.6917, lat: 35.6895, city: "Tokyo", users: 412, size: 10 },
  { lng: -122.4194, lat: 37.7749, city: "San Francisco", users: 298, size: 9 },
  { lng: 2.3522, lat: 48.8566, city: "Paris", users: 187, size: 8 },
  { lng: 77.209, lat: 28.6139, city: "Delhi", users: 156, size: 7 },
  { lng: 151.2093, lat: -33.8688, city: "Sydney", users: 134, size: 7 },
  { lng: -43.1729, lat: -22.9068, city: "Rio", users: 89, size: 6 },
  { lng: 4.9041, lat: 52.3676, city: "Amsterdam", users: 76, size: 5 },
  { lng: 126.978, lat: 37.5665, city: "Seoul", users: 45, size: 5 },
];

export function MapGlobeExample() {
  return (
    <div className="h-[450px] w-full relative overflow-hidden rounded-lg border">
      <div className="absolute top-3 left-3 z-10 bg-background/95 backdrop-blur-md rounded-lg p-3 border border-border/50 shadow-lg">
        <div className="tracking-wider text-[10px] text-muted-foreground uppercase mb-1">
          Active Users
        </div>
        <div className="text-2xl font-semibold leading-tight">2,847</div>
        <div className="flex items-center gap-1 mt-1">
          <TrendingUp className="size-3 text-emerald-500" />
          <span className="text-xs text-emerald-500">+12.5%</span>
          <span className="text-xs text-muted-foreground">vs last hour</span>
        </div>
      </div>

      <div className="absolute bottom-3 left-3 z-10 bg-background/95 backdrop-blur-md rounded-lg px-3 py-2 border border-border/50 shadow-lg">
        <div className="flex items-center gap-4 text-[10px]">
          <div className="flex items-center gap-1.5">
            <div className="size-3 rounded-full bg-emerald-500" />
            <span className="text-muted-foreground">High</span>
          </div>
          <div className="flex items-center gap-1.5">
            <div className="size-2 rounded-full bg-emerald-500" />
            <span className="text-muted-foreground">Medium</span>
          </div>
          <div className="flex items-center gap-1.5">
            <div className="size-1.5 rounded-full bg-emerald-500" />
            <span className="text-muted-foreground">Low</span>
          </div>
        </div>
      </div>

      <Map center={[0, 30]} zoom={1.5} projection={{ type: "globe" }} renderWorldCopies={false}>
        {analyticsData.map((loc) => (
          <MapMarker key={loc.city} longitude={loc.lng} latitude={loc.lat}>
            <MarkerContent>
              <div className="relative flex items-center justify-center">
                <div
                  className="absolute rounded-full bg-emerald-500/20"
                  style={{
                    width: loc.size * 2.5,
                    height: loc.size * 2.5,
                  }}
                />
                <div
                  className="absolute rounded-full bg-emerald-500/40 animate-ping"
                  style={{
                    width: loc.size * 1.5,
                    height: loc.size * 1.5,
                    animationDuration: "2s",
                  }}
                />
                <div
                  className="relative rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/50"
                  style={{ width: loc.size, height: loc.size }}
                />
              </div>
            </MarkerContent>
            <MarkerTooltip>
              <div className="text-center">
                <div className="font-medium">{loc.city}</div>
                <div className="text-emerald-500 font-semibold">
                  {loc.users}
                </div>
                <div className="text-[10px] text-muted-foreground">
                  active users
                </div>
              </div>
            </MarkerTooltip>
          </MapMarker>
        ))}
        <MapControls showZoom showCompass />
      </Map>
    </div>
  );
}

Markers

"use client";

import {
  Map,
  MapMarker,
  MarkerContent,
  MarkerTooltip,
} from "@/components/vritti/map";
import { Zap } from "lucide-react";

type Status = "available" | "in-use" | "offline";

interface ChargingStation {
  name: string;
  lng: number;
  lat: number;
  status: Status;
  detail: string;
}

const stations: ChargingStation[] = [
  { name: "Union Square", lng: -122.4074, lat: 37.7879, status: "available", detail: "50 kW • $0.28/kWh" },
  { name: "Castro Station", lng: -122.435, lat: 37.7625, status: "in-use", detail: "~15 min remaining" },
  { name: "Hayes Valley", lng: -122.4264, lat: 37.7759, status: "offline", detail: "" },
  { name: "Embarcadero", lng: -122.3934, lat: 37.7935, status: "available", detail: "350 kW • $0.40/kWh" },
  { name: "Marina District", lng: -122.437, lat: 37.801, status: "available", detail: "150 kW • $0.32/kWh" },
  { name: "SoMa Charger", lng: -122.401, lat: 37.778, status: "available", detail: "50 kW • $0.30/kWh" },
  { name: "Noe Valley", lng: -122.431, lat: 37.75, status: "available", detail: "150 kW • $0.33/kWh" },
  { name: "Richmond Charger", lng: -122.478, lat: 37.781, status: "in-use", detail: "~8 min remaining" },
  { name: "Potrero Hill", lng: -122.401, lat: 37.76, status: "offline", detail: "" },
  { name: "Mission Bay", lng: -122.391, lat: 37.77, status: "available", detail: "350 kW • $0.38/kWh" },
  { name: "Golden Gate Park", lng: -122.466, lat: 37.77, status: "available", detail: "150 kW • $0.34/kWh" },
];

const statusConfig: Record<Status, { bg: string; label: string; textClass: string }> = {
  available: { bg: "bg-emerald-500", label: "Available", textClass: "text-emerald-500" },
  "in-use": { bg: "bg-amber-500", label: "In Use", textClass: "text-amber-500" },
  offline: { bg: "bg-zinc-400", label: "Offline", textClass: "text-muted-foreground" },
};

export function MapMarkersExample() {
  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[-122.434, 37.776]} zoom={12}>
        {stations.map((station) => {
          const config = statusConfig[station.status];
          return (
            <MapMarker
              key={station.name}
              longitude={station.lng}
              latitude={station.lat}
            >
              <MarkerContent>
                <div className={`${config.bg} rounded-full p-1.5 shadow-lg`}>
                  <Zap className="size-3 fill-white text-white" />
                </div>
              </MarkerContent>
              <MarkerTooltip>
                <div className="space-y-0.5 text-xs">
                  <div className="font-medium">{station.name}</div>
                  <div className="flex items-center gap-1">
                    <span className={`size-1.5 rounded-full ${config.bg}`} />
                    <span className={config.textClass}>{config.label}</span>
                  </div>
                  {station.detail && (
                    <div className="text-muted-foreground">
                      {station.detail}
                    </div>
                  )}
                </div>
              </MarkerTooltip>
            </MapMarker>
          );
        })}
      </Map>
    </div>
  );
}

Popups

"use client";

import {
  Map,
  MapMarker,
  MarkerContent,
  MarkerLabel,
  MarkerPopup,
} from "@/components/vritti/map";
import { Star, Navigation, Clock, ExternalLink } from "lucide-react";

const places = [
  {
    id: 1,
    name: "The Metropolitan Museum of Art",
    label: "Museum",
    category: "Museum",
    rating: 4.8,
    reviews: 12453,
    hours: "10:00 AM - 5:00 PM",
    lng: -73.9632,
    lat: 40.7794,
  },
  {
    id: 2,
    name: "Brooklyn Bridge",
    label: "Landmark",
    category: "Landmark",
    rating: 4.9,
    reviews: 8234,
    hours: "Open 24 hours",
    lng: -73.9969,
    lat: 40.7061,
  },
  {
    id: 3,
    name: "Grand Central Terminal",
    label: "Transit",
    category: "Transit",
    rating: 4.7,
    reviews: 5621,
    hours: "5:15 AM - 2:00 AM",
    lng: -73.9772,
    lat: 40.7527,
  },
];

export function MapPopupsExample() {
  return (
    <div className="h-[500px] w-full overflow-hidden rounded-lg border">
      <Map center={[-73.98, 40.74]} zoom={11}>
        {places.map((place) => (
          <MapMarker key={place.id} longitude={place.lng} latitude={place.lat}>
            <MarkerContent>
              <div className="size-5 rounded-full bg-rose-500 border-2 border-white shadow-lg cursor-pointer hover:scale-110 transition-transform" />
              <MarkerLabel position="bottom">{place.label}</MarkerLabel>
            </MarkerContent>
            <MarkerPopup className="p-0 w-62">
              <div className="space-y-2 p-3">
                <div>
                  <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
                    {place.category}
                  </span>
                  <h3 className="font-semibold text-foreground leading-tight">
                    {place.name}
                  </h3>
                </div>
                <div className="flex items-center gap-3 text-sm">
                  <div className="flex items-center gap-1">
                    <Star className="size-3.5 fill-amber-400 text-amber-400" />
                    <span className="font-medium">{place.rating}</span>
                    <span className="text-muted-foreground">
                      ({place.reviews.toLocaleString()})
                    </span>
                  </div>
                </div>
                <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
                  <Clock className="size-3.5" />
                  <span>{place.hours}</span>
                </div>
                <div className="flex gap-2 pt-1">
                  <button className="flex-1 h-8 inline-flex items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-medium px-3">
                    <Navigation className="size-3.5 mr-1.5" />
                    Directions
                  </button>
                  <button className="h-8 inline-flex items-center justify-center rounded-md border border-input bg-background text-sm px-2 hover:bg-accent">
                    <ExternalLink className="size-3.5" />
                  </button>
                </div>
              </div>
            </MarkerPopup>
          </MapMarker>
        ))}
      </Map>
    </div>
  );
}

Route

import {
  Map,
  MapMarker,
  MarkerContent,
  MarkerTooltip,
  MapRoute,
} from "@/components/vritti/map";

const route = [
  [-74.006, 40.7128], // NYC City Hall
  [-73.9857, 40.7484], // Empire State Building
  [-73.9772, 40.7527], // Grand Central
  [-73.9654, 40.7829], // Central Park
] as [number, number][];

const stops = [
  { name: "City Hall", lng: -74.006, lat: 40.7128 },
  { name: "Empire State Building", lng: -73.9857, lat: 40.7484 },
  { name: "Grand Central Terminal", lng: -73.9772, lat: 40.7527 },
  { name: "Central Park", lng: -73.9654, lat: 40.7829 },
];

export function MapRouteExample() {
  return (
    <div className="h-[400px] w-full overflow-hidden rounded-lg border">
      <Map center={[-73.98, 40.75]} zoom={11.2}>
        <MapRoute coordinates={route} color="#3b82f6" width={4} opacity={0.8} />

        {stops.map((stop, index) => (
          <MapMarker key={stop.name} longitude={stop.lng} latitude={stop.lat}>
            <MarkerContent>
              <div className="size-5 rounded-full bg-blue-500 border-2 border-white shadow-lg flex items-center justify-center text-white text-[10px] font-semibold">
                {index + 1}
              </div>
            </MarkerContent>
            <MarkerTooltip>{stop.name}</MarkerTooltip>
          </MapMarker>
        ))}
      </Map>
    </div>
  );
}

Trail

"use client";

import { Map, MapRoute, MapMarker, MarkerContent } from "@/components/vritti/map";
import { Bike, Flame, Clock, Route } from "lucide-react";

const trailCoordinates: [number, number][] = [
  [-73.95846730810143, 40.80035246904919],
  [-73.9717593682683, 40.78210942124929],
  [-73.98192123136191, 40.76793032580281],
  [-73.97393759456651, 40.76462909128966],
  [-73.97291537521572, 40.765159628993814],
  [-73.96920618484948, 40.7637106622374],
  [-73.96383691302509, 40.77117117897504],
  [-73.9584024523858, 40.76889223221369],
  [-73.9470773638119, 40.784238113060894],
  [-73.95585246901248, 40.78786547226602],
  [-73.94937945594087, 40.79668351998197],
  [-73.9498273526222, 40.797167598041455],
  [-73.95699644240298, 40.80016017872583],
];

const start = trailCoordinates[0];
const end = trailCoordinates[trailCoordinates.length - 1];

export function MapTrailExample() {
  return (
    <div className="h-[450px] w-full relative overflow-hidden rounded-lg border">
      <div className="absolute top-3 left-3 z-10 bg-background/95 backdrop-blur-md rounded-lg p-3 border border-border/50 shadow-lg">
        <div className="flex items-center gap-1.5 mb-2">
          <Bike className="size-3.5 text-emerald-500" />
          <span className="text-xs font-medium">Central Park Loop</span>
        </div>
        <div className="grid grid-cols-3 gap-3 text-center">
          <div>
            <div className="flex items-center justify-center gap-1 text-muted-foreground mb-0.5">
              <Route className="size-3" />
            </div>
            <div className="text-sm font-semibold">6.2</div>
            <div className="text-[9px] text-muted-foreground uppercase">
              Miles
            </div>
          </div>
          <div>
            <div className="flex items-center justify-center gap-1 text-muted-foreground mb-0.5">
              <Clock className="size-3" />
            </div>
            <div className="text-sm font-semibold">32</div>
            <div className="text-[9px] text-muted-foreground uppercase">
              Mins
            </div>
          </div>
          <div>
            <div className="flex items-center justify-center gap-1 text-muted-foreground mb-0.5">
              <Flame className="size-3" />
            </div>
            <div className="text-sm font-semibold">285</div>
            <div className="text-[9px] text-muted-foreground uppercase">
              Cal
            </div>
          </div>
        </div>
      </div>

      <Map center={[-73.965, 40.782]} zoom={13}>
        <MapRoute
          coordinates={trailCoordinates}
          color="#10b981"
          width={3}
          opacity={0.9}
        />

        <MapMarker longitude={start[0]} latitude={start[1]}>
          <MarkerContent>
            <div className="size-3 rounded-full bg-emerald-500 border-2 border-white shadow-lg" />
          </MarkerContent>
        </MapMarker>

        <MapMarker longitude={end[0]} latitude={end[1]}>
          <MarkerContent>
            <div className="size-3 rounded-full bg-red-500 border-2 border-white shadow-lg" />
          </MarkerContent>
        </MapMarker>
      </Map>
    </div>
  );
}