A form input whose UI is a map.
Render a country with its provinces and districts, hold a province to take all of it, tap to zoom in and pick individual districts. You get back a small, canonical value that submits like any other form field.
Ships with Türkiye — 81 provinces (il), 973 districts (ilçe).
npm install react-map-input @react-map-input/data-trimport { GeoInput } from 'react-map-input';
import { turkey } from '@react-map-input/data-tr';
<GeoInput pack={turkey} name="serviceAreas" onChange={(value, summary) => console.log(value, summary)} />type GeoSelection = {
regions: string[]; // fully-selected provinces — implies every district
subregions: string[]; // individual districts inside partially-selected provinces
};It is canonical by construction. Select every district of a province and it collapses to
{ regions: ['TR-34'], subregions: [] }; deselect one and it expands back into the other 38.
One selection state always serialises to exactly one string, so equality is a string compare and
form dirty-checking behaves.
onChange(value, summary) also hands you a derived view — per-province full / partial /
none, resolved names, and counts — so you never recompute it.
| Gesture | Result |
|---|---|
| Hold 500 ms on a province | Select the whole province (a ring fills at the pointer) |
| Release before 500 ms | Zoom into that province's districts |
| Drag more than 10 px | Cancel the hold |
Esc, or tap the sea |
Zoom back out |
Tab / Enter / Space / arrows |
Move / open / select from the keyboard |
Remap any of it with gestures={{ longPressMs, longPress, tap, moveTolerance }}.
Hovering names whatever is under the pointer — a province at country level, a district once you
have zoomed in. Districts are labelled with their province too, because district names are not
unique: Merkez alone identifies 51 different places, Merkez · Kırıkkale identifies one. During a
hold the label names the shape being held, which on touch is the only label there is.
Touch is handled properly: the OS context menu is suppressed, text selection and page panning are disabled over the map, and the synthetic click after a completed hold is swallowed so it cannot fire the tap action as well.
Three levels are drawn, each as an independent stroke-only layer painted over the fills rather than as a stroke on the shapes themselves — so each level's weight, colour and dash pattern is independent of the others and of the selection colour underneath.
| Level | Default style | Shown |
|---|---|---|
| Group — geographic region (bölge) | thick, slate | always |
| Region — province (il) | medium, solid | always |
| Subregion — district (ilçe) | thin, dashed | once zoomed in |
Groups are purely visual and never selectable. Their outlines are dissolved from the same topology as the provinces, so a geographic-region border always follows province borders exactly — it can never cut through the middle of one. The build refuses to emit a pack where a province belongs to no group or to two.
<GeoInput pack={turkey} borders={{ group: false }} />Restyle any level through CSS custom properties:
.rmi-root {
--rmi-group-border: #6b7a8d;
--rmi-group-border-width: 2.25;
--rmi-group-border-dash: none;
--rmi-subregion-border-dash: 2.5 2; /* and -region-* likewise */
}Every stroke uses vector-effect: non-scaling-stroke, so weights stay constant as the map zooms.
While a region is open, everything outside it switches to a muted palette — backdrop, fills and both border levels — and the opened region gets its own outline drawn last, so it reads as "you are here". Neighbouring regions stay clickable, so you can jump straight from one detail view to another.
Recessing them has to change the fill, not just the opacity: the land backdrop is painted in
--rmi-land, the same colour as an unselected region, so fading one over the other composites
--rmi-land onto --rmi-land and changes nothing. Opacity is left to do the one job it is good at
— fading the selection colours, which genuinely differ from what is behind them. The result is
three distinguishable states at once: the opened region at full strength, selected neighbours as
faded blue, everything else recessive.
.rmi-root {
--rmi-land-muted: #e8ecf1;
--rmi-region-border-muted: #dfe5ec;
--rmi-group-border-muted: #b9c4d0;
--rmi-focus-outline: #3d4a5c;
--rmi-focus-outline-width: 2.75;
}Pass name and a hidden input carries the serialised value — plain HTML forms and FormData work
with no adapter:
<form onSubmit={(e) => { e.preventDefault(); new FormData(e.currentTarget).get('serviceAreas'); }}>
<GeoInput pack={turkey} name="serviceAreas" />
</form>Controlled (value + onChange) and uncontrolled (defaultValue) both work. For a completely
custom UI, useGeoSelection() exposes the whole model with no DOM.
Pass a stored value straight back in — this is the edit-an-existing-record path, and it works on first paint:
<GeoInput pack={turkey} defaultValue={JSON.parse(row.serviceAreas)} />Whole provinces render selected immediately, and a province with individually-picked districts renders as partial, both before any district data has loaded. Two details make that hold:
- Codes are never discarded just because they cannot yet be verified. Normalisation keeps a district code whose province has not loaded — it can only be disproved once the real list arrives. A value restored from a database survives the first render intact.
- The districts a value references are fetched automatically, one small chunk per province
actually named, so
summaryreportsKadıköyrather thanTR-34-kadikoy. Provinces selected whole need no fetch at all, since their count is already known.
Your backend has its own identifiers, or keys on names, or wants the parent stored beside the child. A codec is that translation, and it stays out of the component:
import { GeoInput, nestedCodec } from 'react-map-input';
<GeoInput pack={turkey} name="areas" codec={nestedCodec} />[
{ "code": "TR-34", "name": "İstanbul", "all": true, "subregions": [] },
{ "code": "TR-06", "name": "Ankara", "all": false,
"subregions": [{ "code": "TR-06-cankaya", "name": "Çankaya" }] }
]Reading one back is the mirror image:
<GeoInput pack={turkey} defaultValue={nestedCodec.decode(fromBackend, { pack: turkey, index })} />Prefer the nested shape for anything that leaves the browser. It is not just tidier — it is the only one that survives the trip:
- Names stop being ambiguous.
Merkeznames 51 different districts in Türkiye. A flat list of district names is unusable; nested under its province, each one is unique again.decodefalls back to names when codes are missing, so a payload that lost its codes still resolves — and resolves to the rightMerkez. - Decoding needs no global scan. The parent travels with the child, so a stored value reads back without loading all 81 provinces to discover who owns a district.
- A partially-selected province still appears, carrying only the districts actually chosen — usually exactly what a join table wants.
| Codec | Shape | Use it for |
|---|---|---|
compactCodec (default) |
{ regions: [], subregions: [] } |
Smallest; needs no lookups |
nestedCodec |
array of regions with nested subregions | Anything crossing the wire |
namesCodec |
{ "İstanbul": "*", "Ankara": ["Çankaya"] } |
Backends keyed on names |
Write your own by implementing SelectionCodec<T> — two functions, encode and decode.
Packs carry arbitrary attributes, so a backend key is just another meta field. The Türkiye pack
already exposes plateCode on provinces and districtId on districts:
const rows = (await resolveSelection(value, turkey)).subregions
.map((d) => ({ districtId: d.meta.districtId, name: d.name }));A value is deliberately compact — one fully-selected province stands in for all of its districts —
so value alone cannot tell you which 39 districts the user picked. Persisting to a join table or
calling an API usually needs the flat list, and getting there means loading provinces that were
never opened:
import { resolveSelection } from 'react-map-input';
const { regions, subregions, codes } = await resolveSelection(value, turkey);
// codes → ['TR-34-adalar', 'TR-34-arnavutkoy', …]describeSelection(value, pack) gives a short human label instead (İstanbul, Ankara +3 more).
<GeoInput pack={turkey} name="areas" required limits={{ maxRegions: 3 }}
onLimitExceeded={(attempted, limits) => toast(`At most ${limits.maxRegions}`)} />required blocks submission while nothing is selected, through the browser's own constraint
validation — no adapter. (It renders a visually-hidden text input rather than type="hidden",
because hidden inputs are barred from validation entirely.)
A pack localises its nouns through meta.labels — province/district, il/ilçe. The sentences around
them come from messages:
import { GeoInput, trMessages } from 'react-map-input';
<GeoInput pack={turkey} messages={trMessages} />
// or override just one
<GeoInput pack={turkey} messages={{ back: () => 'Geri' }} />The map itself has no visible text; this covers what screen readers announce.
The pack carries the source data's attributes, so no extra lookup table is needed:
import { regionsWhere } from 'react-map-input';
regionsWhere(turkey, (r) => r.meta.region.en === 'Aegean'); // 8
regionsWhere(turkey, (r) => r.meta.isCoastal); // 28
regionsWhere(turkey, (r) => r.meta.population > 1_000_000); // 24| Prop | Description |
|---|---|
pack |
The country data pack |
value / defaultValue / onChange |
Controlled or uncontrolled selection |
name / form / required |
Hidden-input form integration |
codec / serialize |
Shape written to the hidden input (see backend section) |
mode |
'multi' (default) or 'single' |
limits |
{ maxRegions, maxSubregions } |
disabled / readOnly / disabledRegions / disabledSubregions |
Constraints |
gestures |
Gesture table (see above) |
drilledRegion / onDrillIn / onDrillOut |
Control the drill-down externally |
borders |
{ group, region, subregion } — switch each border level on or off |
messages |
Override the strings the accessible layer speaks (see i18n below) |
onLimitExceeded |
Fires with the attempted value when limits refuses a change |
showTooltip / renderTooltip |
Name popover on hover and during a hold (on by default) |
theme |
'light' / 'dark'; otherwise follows an ancestor data-theme, then the OS |
zoomDurationMs / zoomPadding |
Zoom animation |
Geometry is projected at build time, not in the browser, so the runtime ships with zero
dependencies — no d3, no topojson, no projection maths. It renders <path d="…"> and animates a
viewBox.
Provinces and districts live in one shared coordinate space, which is what makes drilling in a
plain viewBox interpolation rather than a re-projection. Province outlines are produced by
dissolving districts after simplification, so a province border is exactly the union of its
districts' borders at every zoom level.
Districts load per province — the full country's districts are 527 KB, but you only ever fetch the one province you opened (median 5.6 KB, ~2 KB gzipped). The provinces layer is 59 KB gzipped.
Accessibility does not rely on interactive SVG, which screen readers handle inconsistently. The map
is aria-hidden and a visually-hidden parallel list of real <button role="checkbox"> elements
carries the semantics, focus order and keyboard interaction from the same state.
Country packs live in this repo, one package per country, and a new one is a single pull request.
packages/data/tr is the worked reference and @react-map-input/geopack is the toolkit its build
is written against. See CONTRIBUTING.md.
packages/
core/ react-map-input MIT, zero runtime deps, no geodata
geopack/ @react-map-input/geopack MIT build-time toolkit
data-tr/ @react-map-input/data-tr ODbL, Türkiye
data-xx/ …one package per country
Boundary coordinates are facts, and facts are thin ground for copyright — in the US, Feist means a pure list of them attracts none. But a database of them is different: the EU and UK grant a sui generis database right over the investment in compiling one, regardless of whether the individual facts are protectable. That right is the reason OpenStreetMap uses ODbL at all.
What ODbL actually asks for is modest:
- Attribute. Credit the source wherever the map is shown. Each pack exposes the exact string as
pack.meta.attribution. - Share alike the database. Publicly distributing a modified version of the data means offering that modified data under ODbL too.
It does not reach your application. Rendering a map from the data produces what ODbL calls a
Produced Work, which needs the credit and nothing more — your own code stays yours. Nor does it
reach this library: the core package is MIT and contains no geodata, and each country's data sits
in its own package with its own LICENSE. Mixed licences inside one repository are ordinary; it is
per-package licensing that matters, not per-repository.
npm install
npm run build:data # build the Türkiye pack (downloads geodata on first run)
npm run dev # playground at http://localhost:5173
npm testCode is MIT. The Türkiye geodata is a separate package under ODbL 1.0 — see
@react-map-input/data-tr.