DEV Community

Cover image for Building an Interactive SVG Map of India with Plain JavaScript
Nikhil Sawant
Nikhil Sawant

Posted on Edited on

Building an Interactive SVG Map of India with Plain JavaScript

Why I built India Map Studio

Interactive maps can look deceptively simple: load an SVG, change a fill colour on hover, and attach a click event. A useful administrative map, however, needs much more than that. It needs consistent identifiers, multiple boundary levels, data joins, accessible interaction, responsive controls and clear information about where its geographic data came from.

I built India Map Studio as an open-source, framework-free foundation for these workflows. It uses plain HTML, CSS, JavaScript and SVG, so the code can be inspected, modified and hosted without a required build system.

The project currently includes 36 states and union territories, 750 district features and 22 working examples covering choropleths, markers, drill-down navigation, rankings, annotations, story maps and exports.

In this article, I’ll explain the decisions behind the project and show how its core interactions work.

Many India map examples solve only one part of the problem. Some provide an SVG without interaction, while others demonstrate hover effects but do not offer stable identifiers for connecting real data. District navigation, accessibility, exports and boundary provenance are often left for each developer to solve again.

I wanted one reusable starting point that could support several kinds of projects:

  • Public-data dashboards
  • Educational and geographic explorers
  • Election or development-project visualizations
  • Tourism and transportation maps
  • Civic-technology applications
  • Printable regional reports

The project was developed incrementally. It began with an interactive national map, then expanded into separate state files, district layers, stable administrative identifiers and deeper navigation. Later milestones added CSV imports, markers, comparisons, annotations, story maps, reporting tools and a guided starter generator.

The objective is not to hide the implementation behind a large abstraction. Developers should be able to inspect the SVG, understand the JavaScript and replace the sample data with their own documented sources.

Why SVG and plain JavaScript?

SVG is a practical format for administrative maps because every geographic feature remains part of the browser’s document structure. A state or district can be represented by a <g> element containing one or more paths, along with readable metadata such as a stable slug and administrative identifier.

That provides several useful capabilities without introducing another rendering library:

  • CSS can control normal, hovered, focused and selected states.
  • Pointer and keyboard events can target individual regions.
  • Feature metadata can live directly in data-* attributes.
  • The map remains sharp at different resolutions.
  • The complete map can be exported as SVG.
  • Browser accessibility attributes can be attached to geographic features.

Canvas and WebGL are better choices for extremely large or continuously animated datasets. India Map Studio instead loads one administrative layer at a time: 36 regions at the national level, then the relevant district or subdivision layer after navigation. This keeps the SVG document manageable while preserving direct interaction with every boundary.

Plain JavaScript was a similar deliberate choice. A map should be usable in a static website, educational project, internal dashboard or existing application without requiring a specific framework or bundler.

Framework-free does not mean structure-free. The project separates its responsibilities:

  • SVG files contain boundary geometry and feature metadata.
  • map-engine.js handles loading, sanitization, feature discovery, hover, focus and selection.
  • Page-specific scripts handle panels, routing, filters and data presentation.
  • An optional Web Component provides a smaller declarative integration.
  • A frozen Version 1 API defines the supported public methods and events.

The engine is dependency-free, but developers can still use it inside React, Vue, Svelte or another framework if required. The boundary assets and browser events do not depend on how the surrounding interface is built.

Loading the India boundary map

The reusable map engine needs a mount element, an SVG source and a way to identify interactive features.

<div class="map-card">
  <p id="map-status" role="status">Loading India map…</p>
  <div id="india-map" aria-label="Interactive map of India"></div>
</div>

<style>
  .map-card {
    max-width: 800px;
    padding: 1rem;
    border: 1px solid #d7d6cc;
    border-radius: 1rem;
    background: #fffdf7;
  }

  #india-map {
    min-height: 500px;
  }

  #india-map svg {
    display: block;
    width: 100%;
    height: 500px;
  }

  #india-map .map-region {
    fill: #dce9df;
    stroke: #607b70;
    stroke-width: 1.2;
    cursor: pointer;
    transition: fill 150ms ease;
  }
</style>

<script src="./map-engine.js"></script>
<script>
  const status = document.querySelector("#map-status");

  const map = new IndiaMapEngine({
    mount: "#india-map",
    src: "./assets/maps/india-states.svg",
    featureSelector: ".map-region",
    featureKey: "slug"
  });

  map.on("mapload", (event) => {
    status.textContent =
      `${event.detail.featureCount} states and union territories loaded`;
  });

  map.on("maperror", () => {
    status.textContent = "The map could not be loaded.";
  });

  map.load();
</script>
Enter fullscreen mode Exit fullscreen mode

map.load() fetches the SVG, sanitizes its contents, mounts it inside the selected element and discovers every .map-region feature. The featureKey option tells the engine to use each feature’s data-slug value as its stable key checks.

Because the SVG is fetched by the browser, the project must be served over HTTP rather than opened directly as a file:// document.
After cloning India Map Studio, a simple local server is enough:

python -m http.server 8000
Enter fullscreen mode Exit fullscreen mode

The page can then be opened through http://localhost:8000.

The example uses relative paths because the engine and boundary assets are part of the cloned repository. For production use, keep the required assets with your application and review the data licence and attribution files for the selected boundary layer.

Adding hover and selection

The engine applies state classes to SVG features, allowing interaction styling to remain in CSS rather than being written into every event handler.

Add the following styles after the base .map-region rule:

#india-map .map-region.is-hovered {
  fill: #f8c56b;
}

#india-map .map-region.is-selected {
  fill: #e66b43;
  stroke: #9d3517;
  stroke-width: 2;
}

#india-map .map-region:focus-visible {
  outline: none;
  stroke: #115d4d;
  stroke-width: 3;
}
Enter fullscreen mode Exit fullscreen mode

Add a live description below the map status:

<p id="map-preview" aria-live="polite">
  Hover over or focus a region.
</p>
Enter fullscreen mode Exit fullscreen mode

Then subscribe to the engine’s interaction events:

const preview = document.querySelector("#map-preview");

function featureLabel(feature) {
  return feature?.attributes?.state || feature?.id || "Unknown region";
}

function showPreview(event) {
  preview.textContent = featureLabel(event.detail.feature);
}

function resetPreview() {
  const selectedId = map.getSelectedId();

  preview.textContent = selectedId
    ? `Selected: ${featureLabel(map.describe(selectedId))}`
    : "Hover over or focus a region.";
}

map.on("featureenter", showPreview);
map.on("featurefocus", showPreview);
map.on("featureleave", resetPreview);
map.on("featureblur", resetPreview);

map.on("selectionchange", (event) => {
  if (!event.detail.id) {
    preview.textContent = "Nothing selected.";
    return;
  }

  const name = featureLabel(event.detail.feature);
  preview.textContent = `Selected: ${name} · ${event.detail.id}`;
});
Enter fullscreen mode Exit fullscreen mode

Pointer entry and keyboard focus both apply the is-hovered class. Clicking a region, or pressing Enter or Space while it is focused, applies is-selected. Selection remains visible after the pointer leaves the feature.

The event detail also includes the stable feature ID. For Maharashtra, for example, the selection event returns maharashtra rather than depending on the text displayed in the interface. This value can later be used for data joins, routing and application state.

Keeping state in CSS classes also prevents data-driven colours and interaction colours from becoming mixed together inside JavaScript. The selected style can remain visually prominent regardless of the region’s normal fill.

Using stable state and district identifiers

A visible place name is not a reliable database key. Names can have alternate spellings, punctuation differences or administrative changes. India Map Studio therefore keeps display names, application identifiers and official administrative codes as separate values.

For example, the Maharashtra feature in the national SVG contains:

<g
  id="state-maharashtra"
  class="map-region"
  data-state="Maharashtra"
  data-slug="maharashtra"
  data-type="State"
  data-region-id="IN-REGION-27"
  tabindex="0"
  role="link"
  aria-label="Open Maharashtra map"
>
  <!-- Boundary paths -->
</g>
Enter fullscreen mode Exit fullscreen mode

The important values serve different purposes:

  • data-state is the human-readable display name.
  • data-slug is the application-friendly key used for URLs and data joins.
  • data-region-id is the project’s namespaced feature identifier.
  • Accessibility attributes describe the feature’s interactive purpose.

Identifiers become hierarchical as the user navigates deeper. Pune district is represented as:

<g
  class="district-region"
  data-district="Pune"
  data-slug="pune"
  data-code="521"
  data-lgd-code="490"
  data-feature-id="IN-REGION-27-DISTRICT-521"
>
  <!-- District boundary paths -->
</g>
Enter fullscreen mode Exit fullscreen mode

Junnar tehsil continues the same pattern:

<g
  class="child-region"
  data-name="Junnar"
  data-slug="junnar"
  data-census-code="04187"
  data-lgd-code="4187"
  data-feature-id="IN-REGION-27-DISTRICT-521-TEHSIL-04187"
>
  <!-- Tehsil boundary paths -->
</g>
Enter fullscreen mode Exit fullscreen mode

This produces identifiers that communicate both hierarchy and feature type:

Region:   IN-REGION-27
District: IN-REGION-27-DISTRICT-521
Tehsil:   IN-REGION-27-DISTRICT-521-TEHSIL-04187
Enter fullscreen mode Exit fullscreen mode

Census and Local Government Directory codes remain separate metadata because they come from different administrative systems and source vintages. The application does not silently treat one code system as another.
For ordinary map data, the slug is usually the simplest join key:

slug,label,value
maharashtra,Maharashtra,82
delhi,Delhi,76
karnataka,Karnataka,91
Enter fullscreen mode Exit fullscreen mode

Using maharashtra as the key is safer than attempting to match user-facing text at runtime. The full namespaced identifier is more suitable when data must distinguish between administrative levels or when several boundary layers are present in the same application.

The boundary registry records each published layer’s identifier, parent layer, feature count, source, source date and verification status. This makes the map structure inspectable instead of leaving identifiers and provenance implicit.

Joining CSV data to the map

A boundary becomes much more useful when application data can be joined to it. The included state demonstration file uses three columns:

slug,name,demo_index
andaman-and-nicobar-islands,Andaman and Nicobar Islands,42
andhra-pradesh,Andhra Pradesh,68
arunachal-pradesh,Arunachal Pradesh,37
assam,Assam,58
Enter fullscreen mode Exit fullscreen mode

The slug column corresponds to the data-slug attribute in the SVG. The remaining columns become metadata associated with that feature.

The following parser is sufficient for this deliberately simple sample file:

function parseCsv(text) {
  const [header, ...rows] = text.trim().split(/\r?\n/);
  const fields = header.split(",");

  return rows.map((row) => {
    const values = row.split(",");

    return Object.fromEntries(
      fields.map((field, index) => [field, values[index]])
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

For production CSV files containing quoted commas, escaped values or multiline fields, use a well-tested CSV parser instead of this minimal example.

A numeric value can be converted into a colour by interpolating between two RGB colours:

function colorFor(value, minimum, maximum) {
  const range = maximum - minimum || 1;
  const ratio = Math.max(
    0,
    Math.min(1, (value - minimum) / range)
  );

  const low = [224, 238, 228];
  const high = [17, 93, 77];

  const color = low.map((channel, index) =>
    Math.round(channel + (high[index] - channel) * ratio)
  );

  return `rgb(${color.join(", ")})`;
}
Enter fullscreen mode Exit fullscreen mode

After the map has loaded, fetch and join the CSV records:

async function applyStateData() {
  const response = await fetch(
    "./sample-data/india-state-demo.csv"
  );

  if (!response.ok) {
    throw new Error(`Dataset request failed (${response.status})`);
  }

  const records = parseCsv(await response.text()).map(
    (record) => ({
      ...record,
      demo_index: Number(record.demo_index)
    })
  );

  map.setData(records);

  const values = records
    .map((record) => record.demo_index)
    .filter(Number.isFinite);

  const minimum = Math.min(...values);
  const maximum = Math.max(...values);

  map.getFeatures().forEach((feature) => {
    const value = feature.data?.demo_index;

    if (Number.isFinite(value)) {
      feature.element.style.setProperty(
        "--data-fill",
        colorFor(value, minimum, maximum)
      );
    }
  });

  status.textContent =
    `${records.length} CSV rows joined to ` +
    `${map.getFeatures().length} regions`;
}

map.on("mapload", () => {
  applyStateData().catch((error) => {
    status.textContent = "The demonstration data could not load.";
    console.error(error);
  });
});
Enter fullscreen mode Exit fullscreen mode

Update the base feature style to read the generated custom property:

#india-map .map-region {
  fill: var(--data-fill, #dce9df);
  stroke: #607b70;
  stroke-width: 1.2;
  cursor: pointer;
  transition: fill 150ms ease;
}
Enter fullscreen mode Exit fullscreen mode

A CSS custom property is used instead of setting an inline fill value directly. This allows the later .is-hovered and .is-selected rules to remain visually stronger than the data colour.

Calling map.setData(records) creates the join but does not decide how the data should be presented. Each feature description then exposes its matched row through feature.data, leaving the application free to create colours, labels, tooltips, rankings or filters.

The included values are synthetic and intended only to demonstrate the workflow. A real project should replace them with documented data and clearly state its source, collection date and unit of measurement.

Accessibility and keyboard navigation

An interactive map should not require precise pointer movement. Every published boundary feature therefore includes a keyboard focus target, an accessible label and an interaction role.

A typical feature contains attributes such as:

<g
  class="map-region"
  data-slug="maharashtra"
  tabindex="0"
  role="link"
  aria-label="Open Maharashtra map"
>
  <!-- Boundary paths -->
</g>
Enter fullscreen mode Exit fullscreen mode

When keyboard support is enabled, the engine provides these controls:
Keys & Behaviour
Arrow Right or Arrow Down -> Move to the next available feature
Arrow Left or Arrow Up -> Move to the previous available feature
Home -> Focus the first feature
End -> Focus the final feature
Enter or Space -> Select the focused feature

Navigation wraps at the beginning and end of the feature collection. When a feature is selected, the engine also applies aria-current="true" along with the visual is-selected class.

A map still needs conventional interface controls and text announcements. Add a clear button and status message outside the SVG:

<div class="map-actions">
  <button id="clear-selection" type="button" disabled>
    Clear selection
  </button>

  <p id="selection-status" role="status" aria-live="polite">
    No region selected.
  </p>
</div>
Enter fullscreen mode Exit fullscreen mode

Connect those controls to the map:

const clearButton =
  document.querySelector("#clear-selection");

const selectionStatus =
  document.querySelector("#selection-status");

map.on("selectionchange", (event) => {
  const selected = Boolean(event.detail.id);

  clearButton.disabled = !selected;

  selectionStatus.textContent = selected
    ? `${featureLabel(event.detail.feature)} selected.`
    : "Selection cleared.";
});

clearButton.addEventListener("click", () => {
  map.clearSelection({ source: "clear-button" });
});
Enter fullscreen mode Exit fullscreen mode

Visible keyboard focus should not depend only on a colour change:

#india-map .map-region:focus-visible {
  outline: none;
  stroke: #115d4d;
  stroke-width: 3;
}

.map-actions button:focus-visible {
  outline: 3px solid rgba(17, 93, 77, 0.3);
  outline-offset: 3px;
}
Enter fullscreen mode Exit fullscreen mode

Users who request reduced motion should not receive unnecessary colour-transition animation:

@media (prefers-reduced-motion: reduce) {
  #india-map .map-region {
    transition: none;
  }
}
Enter fullscreen mode Exit fullscreen mode

The surrounding interface remains important. Search fields need labels, legends must include text, status changes should be announced, and map information should also be available through lists or detail panels where practical.

India Map Studio includes automated desktop and mobile accessibility checks, keyboard-navigation tests and narrow-screen coverage. Automated testing does not replace testing with real keyboards, screen readers and touch devices, but it helps prevent common regressions as new examples are added.

Try the examples

India Map Studio includes a searchable library of runnable examples. Each example focuses on one interaction and includes a collapsed, copy-ready recipe near the bottom of the page.

Searchable India Map Studio example gallery

Some useful starting points are:

Goal Example
Join numeric data and create a colour scale Choropleth
Navigate from India into state district layers Drill-down navigation
Import district-level CSV data CSV district data
Add reservoirs, sanctuaries and railway stations POI layers
Synchronize rankings, statistics and map colours Ranking dashboard
Let users create and save map notes Editable annotations
Present locations as guided chapters Story map
Generate a printable map report Printable report
Search by place name, code or identifier Location finder
Create a standalone project without assembling it manually Starter generator

The complete example library currently contains 22 examples covering data visualization, markers, search, drawing, routes, time series, comparisons, embedding and exports.

Demonstration datasets are intentionally synthetic unless an example explicitly identifies a documented source. They are designed to explain integration patterns and should be replaced before publishing a real-world map.

A useful way to begin is to choose the smallest example that matches one required interaction, copy its recipe and then replace its demonstration data and presentation gradually.

Current limitations and boundary licensing

India Map Studio separates the licence for its application code from the licences for geographic boundary data.

The original application code, documentation, generators and synthetic sample data are released under the MIT licence. However, this licence does not automatically apply to third-party map geometry.

The 36 public state and union-territory maps currently contain 750 district features. They are generated from the MIT-licensed datta07/INDIAN-SHAPEFILES dataset, with attribution. The Pune tehsil demonstration uses Census 2011 geometry from ramSeraph/indian_admin_boundaries, released under CC0.

Resource Licence status Included publicly
Application code, documentation and synthetic samples MIT Yes
Public state and district geometry Upstream MIT licence Yes, with attribution
Pune tehsil geometry CC0 Yes, with requested attribution
Survey of India research layers Redistribution permission not confirmed No — local research only

The public maps represent the vintage of their source datasets rather than a claim that every boundary is administratively current. For example, the current audit compares 750 mapped districts with 784 districts reported by the Local Government Directory on 14 July 2026. Some layers predate later district creations, mergers or renaming.

Even when a state’s district count matches the current reference count, that alone does not verify its names, identifiers, topology or geometry.

More recent experimental layers derived from sources without clearly confirmed redistribution rights are intentionally excluded from the public repository. The project uses release-safe fallback layers instead of distributing questionable geographic data.

Every proposed boundary contribution should provide:

  • The source organisation and dataset
  • A direct source URL
  • The retrieval date and boundary vintage
  • The exact licence and licence URL
  • Required attribution
  • Any transformations performed
  • Feature counts and stable identifiers
  • Confirmation that redistribution and derivative works are permitted

Boundary data without verifiable redistribution rights will not be bundled into the project.

You can inspect the project’s data licence inventory, attribution record, boundary registry and interactive boundary audit.

These maps are intended for visualisation, education and prototyping. They should not be treated as authoritative legal boundary determinations.

Contributing

India Map Studio is open source, and contributions are welcome from developers, GIS enthusiasts, designers, researchers and people familiar with local administrative boundaries.

You can contribute by:

  • Reporting map, interaction or accessibility issues
  • Improving documentation and examples
  • Adding reusable visualisation demos
  • Correcting state or district metadata
  • Improving keyboard and screen-reader support
  • Proposing boundary datasets with clear redistribution rights
  • Adding tests or improving performance
  • Translating interface text
  • Suggesting features for education, tourism, governance and public-data projects

For code contributions, fork the repository, create a focused branch and open a pull request describing the problem and your solution.

Boundary contributions require additional information because geographic data may have licensing and accuracy restrictions. Include the original source, boundary vintage, licence, attribution requirements, transformations and stable identifiers with your proposal.

If you are unsure where to begin, explore the repository’s issues or open a discussion with your idea before implementing a large change.

If India Map Studio helps your project, consider starring the repository. It improves the project’s visibility and helps more developers discover it.

Final thoughts

India Map Studio started as an experiment in making Indian administrative maps easier to explore with standard web technologies. It has grown into a reusable toolkit containing interactive maps, stable identifiers, CSV data joins, markers, ranking dashboards, printable reports, story maps and accessible navigation.

There is still plenty to improve—especially boundary freshness, additional subdivision layers and community-contributed examples. But the project already demonstrates how much can be built with SVG, HTML, CSS and plain JavaScript, without requiring a mapping framework.

Try the live application, explore the source code, and let me know what you build with it.

If you find the project useful, please consider starring or forking it on GitHub.


Disclosure: OpenAI Codex assisted with structuring and editing this article. I reviewed and verified the technical content and remain responsible for the final publication.

Top comments (0)