Skip to main content

MapCanvas

Map and Layersdata

Root MapLibre canvas — theme-aware basemap; the ref is the raw MapLibre Map. With plan it is a non-geographic plan instead (a floor, a plant, a carriage) and every layer inside speaks plan units.

import { MapCanvas } from "@elabs-ai/components-maps";
Install
npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-maps

Skipping the @source line is the most common first failure: Tailwind does not scan node_modules, so every component renders unstyled. The one-time wiring

Prompt for your coding agent

Paste it into Claude Code, Cursor, Codex or any other coding agent. Text in [brackets] is yours to fill in.

Add the brand-ui MapCanvas component (from @elabs-ai/components-maps) to this project.

1. Read https://elabs-ai.com/llms.txt first: it maps every package and the rules. If you can use MCP servers, connect https://elabs-ai.com/mcp and use its search, docs and tokens tools. If not, run the same lookups in the terminal: `npx -y @elabs-ai/components-cli search "<what you need>"` and `npx -y @elabs-ai/components-cli docs <Component>`.
2. Look MapCanvas up before writing code: `npx -y @elabs-ai/components-cli docs MapCanvas` prints its real props, variants, what it pairs with and what to avoid.
3. If @elabs-ai/components-maps is not installed yet: `pnpm add @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-maps` (or `npm install @elabs-ai/components-tokens @elabs-ai/components-ui @elabs-ai/components-maps`). If this is the first brand-ui package in the project, wire it once: import `@elabs-ai/components-tokens/styles.css` plus the `themes/light.css` and `themes/dark.css` stylesheets in the CSS entry, add one Tailwind `@source` line per installed @elabs-ai package (Tailwind does not scan node_modules, so without it everything renders unstyled), and wrap the app root in `ThemeProvider` from `@elabs-ai/components-tokens`.
4. Use it here: [the screen or file, and what it should show]
5. Rules: use only @elabs-ai/components-* components and the semantic tokens — no raw colours, no raw font sizes, no arbitrary Tailwind values. Look up each component's real props with the docs tool before you use it; never guess a prop.
6. Done means: the typecheck passes, `npx -y @elabs-ai/components-cli audit src --strict` reports nothing, and you have opened the app and looked at every screen you touched in the light and the dark theme.

Avoid

  • Hiding MapLibre's attribution control — the default Carto basemap styles/tiles require it.
  • Importing maplibre-gl CSS in the app — MapCanvas imports its own CSS plus the brand popup overrides.
  • Rendering it in an RSC/SSR path with no client boundary — MapLibre needs WebGL.
  • Mapping plan units into DEGREES to fake a plan coordinate system — degrees are not linear on screen, so a 16:9 plan renders at 2:1. Declare `plan` and let createPlanCrs map into normalized Mercator, where a rectangle stays a rectangle at every zoom.
  • Leaving rotation and pitch on for a plan — a tilted floor plan is unreadable. The `plan` prop turns dragRotate, pitchWithRotate and touchPitch off; do not switch them back on.
  • Combining `plan` with a globe projection — a plan is flat; the projection is ignored with a one-time warning.

Works with

Examples

7 live examples

Loading

The `loading` prop shows an accessible overlay while the app fetches map data.

Forced Dark Basemap

Pin the basemap flavor regardless of the active brand theme.

Globe

A globe projection — pairs well with `blank` + data layers for data-viz.

Static Locator

An editorial locator: static (no zoom or pan; tooltips still open), two area markers with a stripe pattern and a vignette glow, inline labels, a globe inset, a scale bar and a key. The map sets no height of its own: it takes 1.6 : 1, and turns square at `narrow` so a phone still gets a map worth reading; there the key moves under the map and Rochester's label (`showAt: { narrow: false }`) steps aside.

Static Mode

`interactive={false}`: wheel, drag, double-click and keys leave the viewport alone and the cursor stays an arrow, but hovering the marker still opens its tooltip. The readout only changes when the viewport does.

Responsive Height

A map in a parent with no height of its own: `height` resolves per tier — here 320 px, and a square at `narrow`.

API

MapCanvas

PropTypeDescription
childrenReactNode
classNamestringAdditional CSS classes for the map container.
themeBasemapThemeBasemap flavor. If not provided, it is derived from the active brand theme (`data-theme` + that theme's own `color-scheme`), then a `dark`/`light` root class, then the OS preference.
styles{ light?: MapStyleOption; dark?: MapStyleOption; }Custom map styles for light and dark themes. Overrides the default Carto styles.
blankbooleanUse a transparent, tile-less basemap instead of the default Carto street basemap — a blank canvas. Used alone it renders nothing; add your own layers on top (`<MapGeoJSON>`, `<MapArc>`, markers, …). Ideal for data visualizations. Ignored when an explicit `styles` prop is provided.
basemapLabelsbooleanDraw the basemap's own place labels. `false` leaves the ground and hides every text label the basemap style carries, so the map's own labels (`MapMarker`'s `label`, `MapAnnotation`) are the only naming on it — the locator recipe, and the default inside `MapInset`, where a full-size label is sliced by the frame. Default `true`.
projectionMapProjectionOptionMap projection: `"mercator"` (MapLibre's default) or `"globe"` for a 3D globe view, or a full MapLibre projection spec. Feature-detected: a MapLibre build that cannot switch projections ignores it (with a console note).
interactiveboolean`false` makes the map STATIC — the editorial / locator default: no zoom, pan, rotate or keyboard handlers, the default cursor and no tab stop (leave `<MapControls>` out of a static map). Hover and click still reach layers and markers, so tooltips keep working. The viewport can still be set in code (`viewport`, the ref). Default `true`.
heightMapResponsive<MapHeight>The map's height: CSS px, or `{ aspect }` (width ÷ height), optionally per tier — `{ base: { aspect: 1.6 }, narrow: { aspect: 1 } }`. Tiers are measured on the map's own width (`narrow` < 480 px, `medium` < 768 px). Unset, the map fills its parent as before; a parent with no height of its own gets `DEFAULT_MAP_HEIGHT` (1.6 : 1, square at `narrow`) instead of 0.
viewportPartial<MapViewport>Controlled viewport. When provided together with `onViewportChange`, the map becomes controlled and the viewport is driven by this prop.
onViewportChange(viewport: MapViewport) => voidCallback fired continuously as the viewport changes (pan, zoom, rotate, pitch). Use standalone to observe changes, or with `viewport` for controlled mode.
loadingbooleanShow a loading overlay on the map (e.g. while the app fetches map data).
planPlanExtent | PlanCrsTurn the canvas into a CUSTOM (non-geographic) plan map: a floor plan, a factory layout, a train carriage, a rack elevation. Pass the plan's extent (`{ width, height }` in the plan's own units) or a `createPlanCrs(…)` result, and every layer inside then speaks PLAN coordinates instead of lng/lat — shapes, routes, markers and popups alike. The plan is fitted on mount, pan and zoom are clamped to its extent, and rotation and pitch are off (a rotated floor plan is unreadable). Pair it with `blank` and, for a picture under the shapes, `<MapPlanImage>`. The plan coordinate system is synthesized on Web Mercator, so it is not a georeference: distances belong to the plan, and a scale bar would lie.
fitPlanbooleanFit a plan map to its extent on mount and whenever the extent changes (default: true). Ignored on a geographic map, and skipped when the camera is driven through `viewport`.

Also accepts: Omit<MapLibreGL.MapOptions, "container" | "style" | "interactive">

MapMarker

PropTypeDescription
childrenReactNodeMarker sub-components (MapMarkerContent, MapMarkerPopup, MapMarkerTooltip, MapMarkerLabel). Optional when `label` alone marks the place — an inline area label, for example.
showAtMapResponsive<boolean>Whether the marker shows at a tier (default `true` everywhere): `{ base: true, narrow: false }` drops it from a phone-width map. The The editorial advice applies — duplicate a marker with a shorter label and the opposite `showAt` rather than cramming one label into both.
labelMapMarkerLabelSpecA text label drawn with the marker (see {@link MapMarkerLabelSpec}).
onClick(e: MouseEvent) => voidCallback when the marker is clicked.
onMouseEnter(e: MouseEvent) => voidCallback when the mouse enters the marker.
onMouseLeave(e: MouseEvent) => voidCallback when the mouse leaves the marker.
onDragStart(lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => voidCallback when a drag starts (requires `draggable`). The plan point is `null` off a plan.
onDrag(lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => voidCallback during a drag (requires `draggable`). The plan point is `null` off a plan.
onDragEnd(lngLat: { lng: number; lat: number }, plan: PlanPoint | null) => voidCallback when a drag ends (requires `draggable`). The plan point is `null` off a plan.
dragLabelstringThe accessible name of a `draggable` marker — say what it moves ("Depot location"). Defaults to the locale seam's generic name. Ignored when the marker is not draggable.

Also accepts: MapPosition, Omit<MarkerOptions, "element">

MapControls

PropTypeDescription
position"top-left" | "top-right" | "bottom-left" | "bottom-right"Position of the controls on the map (default: "bottom-right").
showZoombooleanShow zoom in/out buttons. Default: true on an interactive map, false on a static one (`<MapCanvas interactive={false}>`), whose whole point is a fixed view — set `showZoom` explicitly to override either way.
showCompassbooleanShow a compass button to reset bearing/pitch (default: false).
showLocatebooleanShow a locate button to fly to the user's location (default: false).
showFullscreenbooleanShow a fullscreen toggle button (default: false).
showFitbooleanShow a button that frames the whole plan again — a plan map has an edge to come back to, so this defaults to ON there and off on a geographic map.
fitBounds[[number, number], [number, number]]What the fit button frames. Defaults to the plan's own extent; pass bounds to frame something else (one wing, the selected cluster).
onFit() => voidCalled after the camera has been asked to frame the plan.
classNamestringAdditional CSS classes for the controls container.
onLocate(coords: { longitude: number; latitude: number }) => voidCallback with user coordinates when located.
onLocateError(error: GeolocationPositionError) => voidCallback when geolocation fails or is denied.

MapClusterLayer

PropTypeDescription
datarequiredstring | GeoJSON.FeatureCollection<GeoJSON.Point, P>GeoJSON FeatureCollection data or a URL to fetch GeoJSON from.
clusterMaxZoomnumberMaximum zoom level to cluster points on (default: 14).
clusterRadiusnumberRadius of each cluster when clustering points, in pixels (default: 50).
clusterColors[string, string, string]Colors for cluster circles: [small, medium, large] based on point count. Defaults to the theme's `--success`/`--warning`/`--destructive` tokens.
clusterThresholds[number, number]Point-count thresholds for the color/size steps: [medium, large] (default: [100, 750]).
pointColorstringColor for unclustered individual points. Defaults to the theme's `--primary` token.
onPointClick( feature: GeoJSON.Feature<GeoJSON.Point, P>, coordinates: [number, number], ) => voidCallback when an unclustered point is clicked.
onClusterClick(clusterId: number, coordinates: [number, number], pointCount: number) => voidCallback when a cluster is clicked. If not provided, zooms into the cluster.

MapGeoJSON

PropTypeDescription
datarequiredMapGeoJSONData<P>GeoJSON data (FeatureCollection, Feature, Geometry) or a URL to fetch it from. On a canvas with a `plan` extent the coordinates are PLAN units — `[x, y]` in the plan's own drawing units — and are converted for you.
idstringOptional unique identifier prefix for the source/layers. Auto-generated if not provided.
promoteIdstringFeature property to promote to the feature `id`. Required for hover and selected feature-state (`fillHoverPaint`, `selectedId`) and stable `onHover`/`onClick` payloads.
fillPaintMapFillPaint | falsePaint for the polygon fill layer. Merged on top of a theme-aware neutral default (the `--border` token — a mid neutral that reads on the page surface in every theme). Pass `false` to omit the fill layer entirely (e.g. outlines only).
linePaintMapLinePaint | falsePaint for the outline layer. Merged on top of a hairline default (`line-color` = the `--background` token, `line-width` = 0.5) for thin separators. Override `line-color` if your container differs, or pass `false` to omit the layer.
fillHoverPaintMapFillPaintPaint merged onto the fill layer for the feature under the cursor, applied as a `case` expression keyed on hover feature-state. Requires `promoteId`.
fillOpacitynumberThe fill layer's opacity, 0–1 — shorthand for `fillPaint["fill-opacity"]` (which wins when both are set). Default 1, or 0.25 with a `pattern`, so the stripes read over a tint of the same colour.
patternMapGeoJSONPatternHatch the areas with stripes (see {@link MapGeoJSONPattern}).
vignetteMapGeoJSONVignetteGlow around the areas' edges (see {@link MapGeoJSONVignette}).
lineHoverPaintMapLinePaintThe outline equivalent of `fillHoverPaint`. Requires `promoteId`.
fillSelectedPaintMapFillPaintPaint merged onto the fill layer for the feature(s) named by `selectedId`. Applied outside hover, so a selected feature still looks selected while the cursor is over it. Requires `promoteId`.
lineSelectedPaintMapLinePaintThe outline equivalent of `fillSelectedPaint`. Requires `promoteId`.
selectedIdstring | number | readonly (string | number)[] | nullThe selected feature id(s) — the promoted `promoteId` values. Selection is the consumer's state; this only paints it.
hoveredIdstring | number | nullTake over the highlight: when set, the hover paint follows THIS id instead of the pointer. That is how `MapPlanOverlay` makes a keyboard-focused room light up exactly as a hovered one does — one channel, one appearance.
onClick(e: MapGeoJSONEvent<P>) => voidCallback when a feature is clicked.
onHover(e: MapGeoJSONEvent<P> | null) => voidCallback fired when the hovered feature changes; `null` when the cursor leaves.
interactivebooleanWhether features respond to mouse events. Defaults to `false` on a geographic canvas and `true` on a plan canvas, where the shapes ARE the subject rather than a backdrop.
featureLabel(feature: MapGeoJSONFeature<P>, index: number) => stringThe text of a feature in the keyboard list an `interactive` map renders — one visually-hidden button per feature, so the values a pointer reveals on hover are reachable by Tab and readable by a screen reader (WCAG 2.1.1 and 1.3.1). Say what the region is AND what it is worth — the same sentence a hover readout prints. Defaults to the promoted id.
pickPaddingnumberHow far, in px, a click may miss a shape and still land on it. Defaults to 8 px on a plan (so a 20 px seat is a 36 px touch target) and 0 elsewhere. An exact hit always wins over a padded one.
hitWidthnumberWidth, in px, of the invisible line that makes an outline-only shape touchable (default 16). Only used when `fillPaint` is `false`.
beforeIdstringOptional MapLibre layer id to insert the layers before (z-order control).

MapRoute

PropTypeDescription
idstringOptional unique identifier for the route layer.
coordinatesrequired[number, number][]Array of [longitude, latitude] coordinate pairs defining the route — or, in a `<MapCanvas plan>`, [x, y] pairs in plan units.
colorstringLine color as a CSS color value. Defaults to the theme's `--primary` token.
widthnumberLine width in pixels (default: 3).
opacitynumberLine opacity from 0 to 1 (default: 0.8).
dashArray[number, number]Dash pattern [dash length, gap length] for dashed lines.
directionMapRouteDirectionDraw chevrons along the line to show which way it runs (default: "none"). A conveyor, a one-way aisle and a walking route all need this, and an arrow is a second channel that greyscale keeps.
directionSpacingnumberDistance between chevrons in px (default: 80).
onClick() => voidCallback when the route line is clicked.
onMouseEnter() => voidCallback when the mouse enters the route line.
onMouseLeave() => voidCallback when the mouse leaves the route line.
interactivebooleanWhether the route is interactive — shows a pointer cursor on hover (default: true).

MapPlanImage

PropTypeDescription
srcrequiredMapPlanImageSource | { light: MapPlanImageSource; dark: MapPlanImageSource }The plan picture. Pass `{ light, dark }` to swap it with the theme.
altstringWhat the picture shows, for anyone who cannot see it ("Level 3 floor plan, 14 rooms around a central core"). A plan drawn into WebGL has no accessible name of its own, so this is the only description of it.
idstringOptional identifier for the source/layer. Auto-generated if not provided.
extent{ x: number; y: number; width: number; height: number }Where the picture sits, in PLAN units. Defaults to the whole plan extent, so a single full-bleed plan needs nothing here. Use it to place one wing, one machine cell or one carriage inside a larger plan.
coordinatesMapLibreGL.CoordinatesThe four corners in lng/lat (top-left, top-right, bottom-right, bottom-left) — an escape hatch that also works on an ordinary geographic canvas, like Leaflet's `imageOverlay`. Wins over `extent`.
opacitynumberOpacity, 0–1 (default 1). A photographic or ink-heavy plan usually reads best held back around 0.35 so the data drawn on top stays the focus.
visiblebooleanHide the picture without tearing the source down — a multi-floor switch (default true).
resampling"linear" | "nearest"`"nearest"` keeps CAD line art crisp when zoomed past its native resolution.
beforeIdstringMapLibre layer id to insert this layer before (z-order control).
onLoad() => voidFired once the picture has been fetched and decoded.
onError(error: Error) => voidFired when the picture cannot be loaded. The plan itself keeps working without it.

MapPlanOverlay

PropTypeDescription
regionsrequiredreadonly MapPlanRegion[]The interactive regions, in reading order. Keep the array MEMOIZED — it drives the projection pass.
labelrequiredstringAccessible name for the set of regions ("Rooms on level 3"). Supplied by the consumer so it can be localized.
selectedIdstring | readonly string[] | nullThe selected region id(s) — the same value handed to `<MapGeoJSON selectedId>`.
onSelect(id: string | null, region: MapPlanRegion | null) => voidFired when a region is activated by click, `Enter` or `Space`; `null` on `Escape`.
onActiveChange(id: string | null, region: MapPlanRegion | null) => voidFired when the pointer or keyboard focus moves to another region. Feed it to `<MapGeoJSON hoveredId>` so the shape and the proxy highlight together.
modeMapPlanOverlayMode`"regions"` (default) puts one button on every region. `"groups"` puts one button on each `group` instead, and `Enter` descends into its members — the answer for a plan with more regions than a keyboard can reasonably walk (a train with 192 seats), where the first question is always "which coach?".
describeGroup(group: MapPlanGroup) => stringThe accessible description of a group's button — "64 seats", localized by the consumer. Group mode only.
showLabelsbooleanDraw each region's label in the overlay (default true).
labelMinZoomnumberHide the labels below this zoom, when a fitted plan is too small to read.
capturePointerbooleanLet the regions take the pointer as well as the keyboard (default true). With `false` the overlay is keyboard-only and clicks reach the canvas, where `<MapGeoJSON onClick>` picks the shape itself — the right choice for interlocking, non-rectangular shapes whose boxes overlap.
classNamestringExtra classes for the overlay root.

Theming

The tokens each state resolves to. Change the token and every theme follows.

StateToken
loadingloading → Spinner overlay (role="status")