Terrae
uimapsource: ok · hit
Map
Core map component with Mapbox GL and MapLibre GL support.
npx shadcn add "https://www.terrae.dev/map.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files4 · source cached 10/15/2026, 12:03:54 AM
src/registry/map/map.tsx
"use client"
import { useTheme } from "next-themes"
import { createContext, useEffect, useRef, useState, type ReactNode } from "react"
import type mapboxgl from "mapbox-gl"
import { mapgl, detectedLibrary } from "./map-library"
import { Globe } from "lucide-react"
import {
defaultMapStyles,
defaultMapLibreStyles,
type MapContextValue,
type MapThemeStyles,
type MapProjection,
type MapCoordinates,
type MapBounds,
} from "./types"
export const MapContext = createContext<MapContextValue | null>(null)
const DEFAULT_CENTER: MapCoordinates = [0, 0]
const DEFAULT_ZOOM = 2
const DEFAULT_BEARING = 0
const DEFAULT_PITCH = 0
const DEFAULT_ROTATE_SPEED = 3
type MapProps = {
accessToken?: string
children?: ReactNode
loader?: ReactNode
// Forces loader to show when true, hides when false, auto when undefined
showLoader?: boolean
// Overrides theme-based styles when set
style?: string
styles?: MapThemeStyles
center?: MapCoordinates
zoom?: number
bearing?: number
pitch?: number
projection?: MapProjection
minZoom?: number
maxZoom?: number
maxBounds?: MapBounds
// Auto-rotate the globe (only works with projection="globe")
autoRotate?: boolean
// Rotation speed in degrees per second
rotateSpeed?: number
}
export const Map = ({
accessToken,
children,
loader,
showLoader,
style,
styles,
center = DEFAULT_CENTER,
zoom = DEFAULT_ZOOM,
bearing = DEFAULT_BEARING,
pitch = DEFAULT_PITCH,
projection,
minZoom,
maxZoom,
maxBounds,
autoRotate,
rotateSpeed = DEFAULT_ROTATE_SPEED,
}: MapProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<mapboxgl.Map | null>(null)
const [isLoaded, setIsLoaded] = useState(false)
const [error, setError] = useState<string | null>(null)
const { resolvedTheme } = useTheme()
const initializedRef = useRef(false)
const shouldShowLoader = showLoader ?? !isLoaded
const getMapStyle = () => {
if (style) {
return style
}
const defaults = detectedLibrary === "maplibre" ? defaultMapLibreStyles : defaultMapStyles
const darkStyle = styles?.dark ?? defaults.dark
const lightStyle = styles?.light ?? defaults.light
return resolvedTheme === "dark" ? darkStyle : lightStyle
}
const createMapInstance = (container: HTMLDivElement) => {
return new mapgl.Map({
container,
style: getMapStyle(),
center,
zoom,
bearing,
pitch,
projection,
minZoom,
maxZoom,
maxBounds,
attributionControl: false,
})
}
const isStandardStyle = (styleUrl: string) => {
return styleUrl.includes("mapbox://styles/mapbox/standard")
}
const updateStandardLightPreset = (mapInstance: mapboxgl.Map) => {
if (detectedLibrary !== "mapbox") {
return
}
const currentStyle = getMapStyle()
if (isStandardStyle(currentStyle)) {
const lightPreset = resolvedTheme === "dark" ? "night" : "day"
mapInstance.setConfigProperty("basemap", "lightPreset", lightPreset)
}
}
const handleMapLoad = () => {
setIsLoaded(true)
if (mapRef.current) {
updateStandardLightPreset(mapRef.current)
}
}
const handleMapError = (e: mapboxgl.ErrorEvent) => {
console.error("Map error:", e.error)
setError("Failed to load map")
}
const cleanupMap = (mapInstance: mapboxgl.Map) => {
mapInstance.remove()
mapRef.current = null
setIsLoaded(false)
initializedRef.current = false
}
useEffect(() => {
if (initializedRef.current) {
return
}
if (!containerRef.current) {
return
}
if (detectedLibrary === "mapbox" && !accessToken) {
setError(
"Mapbox access token is required. Add NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN to your .env.local file and restart the dev server."
)
return
}
initializedRef.current = true
if (accessToken) {
mapgl.accessToken = accessToken
}
try {
const container = containerRef.current
const originalGetBoundingClientRect = container.getBoundingClientRect.bind(container)
container.getBoundingClientRect = () => {
const rect = originalGetBoundingClientRect()
const width = container.offsetWidth
const height = container.offsetHeight
return { ...rect, width, height, right: rect.left + width, bottom: rect.top + height }
}
const mapInstance = createMapInstance(container)
mapInstance.on("load", handleMapLoad)
mapInstance.on("error", handleMapError)
mapRef.current = mapInstance
return () => {
delete (container as unknown as Record<string, unknown>).getBoundingClientRect
cleanupMap(mapInstance)
}
} catch (err) {
console.error("Error creating map:", err)
setError("Failed to create map")
initializedRef.current = false
}
}, [])
useEffect(() => {
if (!mapRef.current || !isLoaded) {
return
}
const currentStyle = getMapStyle()
if (isStandardStyle(currentStyle)) {
updateStandardLightPreset(mapRef.current)
} else {
mapRef.current.setStyle(currentStyle)
}
}, [style, styles, resolvedTheme])
useEffect(() => {
if (!mapRef.current || !isLoaded) {
return
}
mapRef.current.setCenter(center)
}, [center])
useEffect(() => {
if (!mapRef.current || !isLoaded) {
return
}
mapRef.current.setZoom(zoom)
}, [zoom])
useEffect(() => {
if (!mapRef.current || !isLoaded) {
return
}
mapRef.current.setBearing(bearing)
}, [bearing])
useEffect(() => {
if (!mapRef.current || !isLoaded) {
return
}
mapRef.current.setPitch(pitch)
}, [pitch])
useEffect(() => {
if (!mapRef.current || !isLoaded || !projection) {
return
}
mapRef.current.setProjection(projection)
}, [projection])
useEffect(() => {
if (!containerRef.current || !mapRef.current || !isLoaded) {
return
}
const container = containerRef.current
const observer = new ResizeObserver(() => {
mapRef.current?.resize()
})
observer.observe(container)
return () => {
observer.disconnect()
}
}, [isLoaded])
useEffect(() => {
if (!mapRef.current || !isLoaded || !autoRotate || projection !== "globe") {
return
}
let animationId: number
let lastTime = performance.now()
const rotate = (currentTime: number) => {
if (!mapRef.current) {
return
}
const delta = (currentTime - lastTime) / 1000
lastTime = currentTime
const currentCenter = mapRef.current.getCenter()
const newLng = currentCenter.lng + rotateSpeed * delta
mapRef.current.setCenter([newLng, currentCenter.lat])
animationId = requestAnimationFrame(rotate)
}
animationId = requestAnimationFrame(rotate)
return () => {
cancelAnimationFrame(animationId)
}
}, [isLoaded, autoRotate, projection, rotateSpeed])
const contextValue: MapContextValue = {
map: mapRef.current,
isLoaded,
library: detectedLibrary,
}
if (error) {
return (
<div className="relative w-full h-full">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-destructive text-sm">{error}</div>
</div>
</div>
)
}
return (
<MapContext.Provider value={contextValue}>
<div ref={containerRef} className="relative w-full h-full">
{shouldShowLoader && (loader || <DefaultLoader />)}
{mapRef.current && children}
</div>
</MapContext.Provider>
)
}
const DefaultLoader = () => {
return (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-muted">
<Globe className="size-8 text-muted-foreground/60 animate-spin" />
</div>
)
}
src/registry/map/types.ts
import { type RefObject } from "react"
import type { Map, Marker, ProjectionSpecification } from "mapbox-gl"
import type { MapLibraryName } from "./map-library"
export type { MapLibraryName }
export type MapCoordinates = [longitude: number, latitude: number]
export type MapBounds = [southwest: MapCoordinates, northeast: MapCoordinates]
export type MapPath = MapCoordinates[]
export type MapImageCorners = [
topLeft: MapCoordinates,
topRight: MapCoordinates,
bottomRight: MapCoordinates,
bottomLeft: MapCoordinates,
]
export type MapProjection = ProjectionSpecification["name"]
export type LngLatCoordinates = {
lng: number
lat: number
}
export type MapThemeStyles = {
light?: string
dark?: string
}
export const defaultMapStyles: Required<MapThemeStyles> = {
light: "mapbox://styles/mapbox/light-v11",
dark: "mapbox://styles/mapbox/dark-v11",
}
export const standardMapStyles: Required<MapThemeStyles> = {
light: "mapbox://styles/mapbox/standard",
dark: "mapbox://styles/mapbox/standard",
}
export const streetsMapStyles: Required<MapThemeStyles> = {
light: "mapbox://styles/mapbox/streets-v12",
dark: "mapbox://styles/mapbox/dark-v11",
}
export const outdoorsMapStyles: Required<MapThemeStyles> = {
light: "mapbox://styles/mapbox/outdoors-v12",
dark: "mapbox://styles/mapbox/dark-v11",
}
export const satelliteMapStyles: Required<MapThemeStyles> = {
light: "mapbox://styles/mapbox/satellite-streets-v12",
dark: "mapbox://styles/mapbox/satellite-streets-v12",
}
export const navigationMapStyles: Required<MapThemeStyles> = {
light: "mapbox://styles/mapbox/navigation-day-v1",
dark: "mapbox://styles/mapbox/navigation-night-v1",
}
export const defaultMapLibreStyles: Required<MapThemeStyles> = {
light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json",
dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
}
export type MapCompareOrientation = "horizontal" | "vertical"
export type MapSyncLayout = "horizontal" | "vertical" | "grid"
export type MapContextValue = {
map: Map | null
isLoaded: boolean
library: MapLibraryName
}
export type MarkerContextValue = {
markerRef: RefObject<Marker | null>
markerElementRef: RefObject<HTMLDivElement | null>
map: Map | null
isReady: boolean
}
src/registry/map/hooks.ts
import { useContext } from "react"
import { MapContext } from "./map"
export const useMap = () => {
const context = useContext(MapContext)
if (!context) {
return { map: null, isLoaded: false, library: "mapbox" as const }
}
return context
}
src/registry/map/map-library.ts
import mapboxgl from "mapbox-gl"
import "mapbox-gl/dist/mapbox-gl.css"
export type MapLibraryName = "mapbox" | "maplibre"
// Change these two lines to switch between Mapbox GL and MapLibre GL:
// import mapboxgl from "maplibre-gl"
// import "maplibre-gl/dist/maplibre-gl.css"
// const detectedLibrary: MapLibraryName = "maplibre"
const detectedLibrary: MapLibraryName = "mapbox"
const mapgl = mapboxgl
export { mapgl, detectedLibrary }