shadimage
Mapcn
blockchoroplethsource: ok · refreshed

Choropleth

World choropleth map shading countries by a metric, with a hover tooltip and legend.

npx shadcn add "https://mapcn.dev/r/choropleth.json"
free

Where it goes

stats

Interaction

animated-countertooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
next-themes

Registry dependencies

@mapcn/map

Files3 · source cached 10/8/2026, 7:44:25 AM

src/registry/blocks/choropleth/page.tsx
"use client";

import { useMemo, useState } from "react";
import { useTheme } from "next-themes";

import { Map, MapControls, MapGeoJSON, MapPopup } from "@/components/ui/map";
import { useWorldData } from "@/lib/use-world-data";
import { mapConfig, visitorsByCountry, type Theme } from "./data";

// WebGL paint can't read CSS variables, so we build a concrete value→color
// expression per theme. The hovered country is highlighted in place via the
// `hover` feature-state, restricted to countries that actually have data.
function buildFillColor(theme: Theme): unknown[] {
  const { base, ramp, hover } = mapConfig.colors[theme];
  const [s0, s1, s2, s3, s4] = mapConfig.scaleStops;
  const ramped = [
    "interpolate",
    ["linear"],
    ["coalesce", ["get", "visitors"], 0],
    s0,
    base,
    s1,
    ramp[0],
    s2,
    ramp[1],
    s3,
    ramp[2],
    s4,
    ramp[3],
  ];
  return [
    "case",
    [
      "all",
      ["boolean", ["feature-state", "hover"], false],
      [">", ["coalesce", ["get", "visitors"], 0], 0],
    ],
    hover,
    ramped,
  ];
}

const legendGradientStyle = {
  "--choropleth-ramp-light": `linear-gradient(to right, ${mapConfig.colors.light.ramp.join(", ")})`,
  "--choropleth-ramp-dark": `linear-gradient(to right, ${mapConfig.colors.dark.ramp.join(", ")})`,
} as React.CSSProperties;

interface HoverInfo {
  name: string;
  visitors: number;
  lng: number;
  lat: number;
}

interface CountryProperties {
  NAME_LONG: string;
  visitors: number;
}

type CountryFeatureCollection = GeoJSON.FeatureCollection<
  GeoJSON.Geometry,
  CountryProperties
>;

export default function Page() {
  const { resolvedTheme } = useTheme();
  const theme: Theme = resolvedTheme === "dark" ? "dark" : "light";
  const [hover, setHover] = useState<HoverInfo | null>(null);
  const world = useWorldData();

  const countries = useMemo<CountryFeatureCollection | null>(() => {
    if (!world) return null;
    return {
      type: "FeatureCollection",
      features: world.features.map((f) => ({
        ...f,
        properties: {
          NAME_LONG: f.properties.NAME_LONG,
          visitors: visitorsByCountry[f.properties.NAME_LONG] ?? 0,
        },
      })),
    };
  }, [world]);

  // Recompute paint only when the theme changes; MapGeoJSON recolors in place.
  const fillPaint = useMemo(
    () => ({
      "fill-color": buildFillColor(theme) as never,
      "fill-opacity": 0.92,
    }),
    [theme],
  );

  return (
    <div className="bg-card relative h-screen overflow-hidden">
      <Map
        blank
        center={mapConfig.view.center}
        zoom={mapConfig.view.zoom}
        minZoom={mapConfig.view.minZoom}
        maxZoom={mapConfig.view.maxZoom}
        scrollZoom={false}
        dragRotate={false}
        pitchWithRotate={false}
        loading={!countries}
      >
        {countries && (
          <MapGeoJSON<CountryProperties>
            data={countries}
            promoteId="NAME_LONG"
            fillPaint={fillPaint}
            interactive
            onHover={(e) => {
              const visitors = e?.feature.properties.visitors ?? 0;
              // Only countries with data are interactive.
              if (!e || visitors <= 0) {
                setHover(null);
                return;
              }
              setHover({
                name: e.feature.properties.NAME_LONG,
                visitors,
                lng: e.longitude,
                lat: e.latitude,
              });
            }}
          />
        )}
        <MapControls className="bottom-2" />
        {hover && (
          <MapPopup
            longitude={hover.lng}
            latitude={hover.lat}
            offset={12}
            closeOnClick={false}
            className="pointer-events-none p-2"
          >
            <p className="text-xs font-medium">{hover.name}</p>
            <div className="flex items-center justify-between gap-4 pt-1">
              <span className="text-muted-foreground flex items-center gap-1.5 text-[11px]">
                <span
                  className="size-2 rounded-full"
                  style={{ backgroundColor: mapConfig.colors[theme].hover }}
                />
                Visitors
              </span>
              <span className="text-foreground text-xs font-semibold tabular-nums">
                {hover.visitors.toLocaleString()}
              </span>
            </div>
          </MapPopup>
        )}
      </Map>

      <div
        className="bg-card absolute bottom-4 left-4 z-10 rounded-lg border px-3 py-2.5 backdrop-blur-sm"
        style={legendGradientStyle}
      >
        <p className="text-foreground text-xs font-medium">
          Visitors by country
        </p>
        <div className="mt-2 h-2 w-40 rounded-full [background-image:var(--choropleth-ramp-light)] dark:[background-image:var(--choropleth-ramp-dark)]" />
        <div className="text-muted-foreground flex items-center justify-between pt-1.5 text-[10px]">
          <span>Low</span>
          <span>High</span>
        </div>
      </div>
    </div>
  );
}
src/registry/blocks/choropleth/data.ts
/** Visitor counts keyed by the feature `NAME_LONG`. */
export const visitorsByCountry: Record<string, number> = {
  "United States": 100,
  Canada: 70,
  Brazil: 65,
  "United Kingdom": 95,
  Germany: 80,
  France: 55,
  India: 90,
  China: 45,
  Japan: 35,
  Australia: 25,
  "South Africa": 20,
  Egypt: 15,
};
export type Theme = "light" | "dark";

/** Resolved colors for the choropleth, per theme. */
interface ChoroplethColors {
  /** Fill for countries with no data (value 0). */
  base: string;
  /** Sequential fill ramp from low → high, mapped to `scaleStops`. */
  ramp: [string, string, string, string];
  /** Fill for the hovered country. */
  hover: string;
}

/**
 * Central map config: colors, the value→color scale, and the initial view.
 * Colors are concrete hex values (not CSS variables) since WebGL paint can't
 * read them. Update `colors.light`/`colors.dark` below for custom colors.
 */
export const mapConfig = {
  view: {
    center: [12, 28] as [number, number],
    zoom: 1.4,
    minZoom: 1,
    maxZoom: 4,
  },
  scaleStops: [0, 25, 50, 75, 100] as const,
  colors: {
    light: {
      base: "#f0f0f0",
      ramp: ["#d4d4d4", "#a3a3a3", "#737373", "#404040"],
      hover: "#0a0a0a",
    },
    dark: {
      base: "#2a2a2a",
      ramp: ["#404040", "#737373", "#a3a3a3", "#d4d4d4"],
      hover: "#ffffff",
    },
  } satisfies Record<Theme, ChoroplethColors>,
};
src/lib/use-world-data.ts
"use client";

import { useEffect, useState } from "react";

export const WORLD_GEOJSON =
  "https://cdn.jsdelivr.net/gh/nvkelso/natural-earth-vector@v5.1.2/geojson/ne_110m_admin_0_countries.geojson";

export interface WorldFeatureProperties {
  NAME_LONG: string;
}

export type WorldData = GeoJSON.FeatureCollection<
  GeoJSON.Geometry,
  WorldFeatureProperties
>;

export function useWorldData(url: string = WORLD_GEOJSON): WorldData | null {
  const [data, setData] = useState<WorldData | null>(null);

  useEffect(() => {
    let active = true;
    fetch(url)
      .then((res) => res.json() as Promise<WorldData>)
      .then((world) => {
        if (active) setData(world);
      });
    return () => {
      active = false;
    };
  }, [url]);

  return data;
}

More from Mapcn

Browse all components in this library →