Rushan

Interactive SVG Maps in React: Clickable Regions Without a Map Library

  • react
  • interactive-maps
  • data-visualization
  • svg-icons
  • tutorial
  • geoicons

When building a dashboard or a settings screen, sooner or later someone asks for a map. Maybe your sales team wants to pick the states they cover, or your analytics page needs to show where new signups come from.

The usual answer is a mapping library like Leaflet, Mapbox, or react-simple-maps, plus a TopoJSON file of boundaries. Those tools are excellent at zooming, panning, and placing points by latitude and longitude. But if all you need is "let me click Texas" or "show California darker because it has more users", you end up loading and styling a whole map engine for a job a few SVG shapes can handle.

In this tutorial, we'll build two interactive SVG map components in React using GeoIcons, a library of country, region, and state outlines shipped as tree-shakable components:

  1. A clickable region picker where users toggle states on and off.
  2. A choropleth-style grid that colors each state by a data value, with a legend.

Key takeaways

  • Each GeoIcons shape is a React component that renders an inline <svg>, so you can wrap it in a <button>, color it with Tailwind, and fill it with currentColor.
  • Put the click handler and aria-pressed on a real <button> and mark the icon aria-hidden. Keyboard users can then tab to each state and toggle it with Space or Enter.
  • For data coloring, sort values into a few buckets and write each bucket as a full Tailwind class name.
  • Each icon fills its own 24×24 frame. When regions must sit in their true geographic positions, use a map library instead.

Step 1: Install GeoIcons

Add the React package to your project:

npm i @geoicons/react

The states and provinces live in the @geoicons/react/subdivisions entry point: all 50 US states plus Washington, D.C., Canada's provinces and territories, Mexico's states, and the parishes and departments of Central America and the Caribbean. Each name joins the country code and the region name, so Texas is UsTexas and New York is UsNewYork.

Step 2: Build a clickable region picker

We'll start with a list of states. Each entry holds a stable id, a readable name, and the icon component. Then we render one toggle button per state:

import { useState } from 'react';
import { UsCalifornia, UsTexas, UsNewYork, UsFlorida } from '@geoicons/react/subdivisions';

const regions = [
  { id: 'us-california', name: 'California', Icon: UsCalifornia },
  { id: 'us-texas', name: 'Texas', Icon: UsTexas },
  { id: 'us-new-york', name: 'New York', Icon: UsNewYork },
  { id: 'us-florida', name: 'Florida', Icon: UsFlorida },
];

export function RegionPicker() {
  const [selected, setSelected] = useState<Set<string>>(() => new Set(['us-texas']));

  const toggle = (id: string) => {
    setSelected((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        next.add(id);
      }
      return next;
    });
  };

  return (
    <div className="flex flex-wrap gap-3">
      {regions.map(({ id, name, Icon }) => {
        const isOn = selected.has(id);
        return (
          <button
            key={id}
            type="button"
            aria-pressed={isOn}
            onClick={() => toggle(id)}
            className={`flex w-28 flex-col items-center gap-2 rounded-lg border p-3 text-sm transition-colors ${
              isOn
                ? 'border-blue-600 bg-blue-50 text-blue-700'
                : 'border-slate-200 text-slate-500 hover:text-slate-800'
            }`}
          >
            <Icon
              size={48}
              fill={isOn ? 'currentColor' : 'none'}
              fillOpacity={0.15}
              aria-hidden="true"
            />
            {name}
          </button>
        );
      })}
    </div>
  );
}
Demo

California

Texas

New York

Florida

Let's walk through what each piece does:

  • selected is a Set of ids, so checking whether a state is on is a single has() call.
  • Each state is a real <button> with aria-pressed. Screen readers announce it as a toggle button and tell the user whether it is pressed.
  • The state name sits inside the button as visible text, which gives the button its accessible name. That is why the icon gets aria-hidden="true".
  • When a state is selected, we switch fill to currentColor with fillOpacity={0.15}. The shape gets a light tint in the same blue as its outline, so the selection is visible even without the border change.

Step 3: Color states by data

A choropleth map uses color intensity to show a value for each region. We'll get the same effect with a grid of state tiles. Suppose your API returns signup counts per state:

import { UsCalifornia, UsTexas, UsNewYork, UsFlorida, UsIllinois, UsWashington } from '@geoicons/react/subdivisions';

const signups = [
  { name: 'California', value: 4210, Icon: UsCalifornia },
  { name: 'Texas', value: 3120, Icon: UsTexas },
  { name: 'New York', value: 2480, Icon: UsNewYork },
  { name: 'Florida', value: 1760, Icon: UsFlorida },
  { name: 'Illinois', value: 940, Icon: UsIllinois },
  { name: 'Washington', value: 610, Icon: UsWashington },
];

// Darkest first. Full class names, so Tailwind can find them.
const buckets = [
  { min: 3000, label: '3,000+', className: 'text-blue-700' },
  { min: 2000, label: '2,000 to 2,999', className: 'text-blue-500' },
  { min: 1000, label: '1,000 to 1,999', className: 'text-blue-400' },
  { min: 0, label: 'Under 1,000', className: 'text-blue-200' },
];

const colorFor = (value: number) =>
  buckets.find((bucket) => value >= bucket.min)?.className ?? 'text-slate-300';

export function SignupsByState() {
  return (
    <figure>
      <ul className="grid grid-cols-2 gap-3 sm:grid-cols-3">
        {signups.map(({ name, value, Icon }) => (
          <li key={name} className="flex items-center gap-3 rounded-lg border p-3">
            <Icon size={40} fill="currentColor" className={colorFor(value)} aria-hidden="true" />
            <span className="text-sm">
              <span className="block font-medium">{name}</span>
              {value.toLocaleString()} signups
            </span>
          </li>
        ))}
      </ul>

      <figcaption className="mt-4 flex flex-wrap gap-4 text-xs text-slate-500">
        {buckets.map((bucket) => (
          <span key={bucket.label} className="flex items-center gap-1.5">
            <span className={`size-3 rounded-sm bg-current ${bucket.className}`} />
            {bucket.label}
          </span>
        ))}
      </figcaption>
    </figure>
  );
}
Demo
  • California4,210 signups
  • Texas3,120 signups
  • New York2,480 signups
  • Florida1,760 signups
  • Illinois940 signups
  • Washington610 signups

3,000+2,000 to 2,9991,000 to 1,999Under 1,000

You might wonder why we use buckets instead of computing a color for every value. Tailwind only generates the classes it finds written in your source files, so a class built at runtime like text-blue-${shade} never makes it into your CSS. Writing the four full class names in the buckets array keeps them visible to Tailwind. Four distinct shades are also easier to tell apart than a smooth gradient.

Notice that each tile shows its number as text. The grid never depends on color alone, which helps colorblind users and anyone reading the page with a screen reader. The legend uses bg-current on a small swatch, so each swatch always matches the shade its bucket gives the icons.

Step 4: Look up icons from API ids

Real data usually arrives as ids, not components. GeoIcons subdivision ids follow a <country code>-<name> pattern such as us-texas and us-new-york, so you can key a lookup object with the same strings your API sends:

import { UsCalifornia, UsTexas, UsNewYork } from '@geoicons/react/subdivisions';

// Every icon shares the same props, so any one of them works as the type.
type StateIcon = typeof UsTexas;

const stateIcons: Record<string, StateIcon> = {
  'us-california': UsCalifornia,
  'us-texas': UsTexas,
  'us-new-york': UsNewYork,
};

// rows from your API: [{ id: 'us-texas', value: 3120 }, ...]
// colorFor() is the bucket helper from Step 3.
function StateTile({ id, value }: { id: string; value: number }) {
  const Icon = stateIcons[id];
  return Icon ? <Icon size={40} fill="currentColor" className={colorFor(value)} aria-hidden="true" /> : null;
}

Listing the states your dashboard shows keeps the bundle small, at roughly 0.3 KB gzipped per icon. If you instead write import * as states and look icons up with states[id], your bundler can't tell which ones you use, so it ships every subdivision. The tree-shaking guide explains why.

When to reach for a real map library

GeoIcons scales every shape to fill its own 24×24 frame. Rhode Island and Texas appear at the same size, and the tiles don't snap together into one continuous map. For many dashboards that is what you want, because every state stays readable. It is the wrong tool when:

  • regions must sit in their true geographic positions next to each other,
  • users need to zoom, pan, or drop pins by latitude and longitude,
  • you need boundaries GeoIcons doesn't include, such as counties or postal codes.

For those cases, use a mapping tool like react-simple-maps, d3-geo, or Leaflet. GeoIcons still fits around the map, in the filter chips above it or the legend beside it.

FAQ

Can I build an interactive SVG map in React without Leaflet or Mapbox?
Yes, when you need clickable regions or data colors rather than zoom, pan, and geographic positioning. Render each region as a GeoIcons component inside a button, track the selection in React state, and color the shapes with currentColor.
How do I make a choropleth map accessible?
Don't rely on color alone. Show each region's value as text next to its shape, add a legend that lists the range for each shade, and use real buttons with aria-pressed for anything users can click.
Why do all the states appear the same size?
Each GeoIcons shape is scaled to fill a 24x24 frame, so small regions like Rhode Island stay recognizable next to large ones like Texas. If relative size or position matters for your data, use a map projection library such as d3-geo instead.

Wrapping up

We built a toggleable region picker and a data-colored state grid with plain React state, Tailwind classes, and GeoIcons components. Both work with a keyboard and a screen reader, and your bundle only carries the states you import.

Browse the subdivisions catalog to find the states and provinces you need, or read the React map icons guide for sizing, styling, and runtime lookups.