Terrae
uicamera-followsource: ok · hit
Map Camera Follow
Animate camera along a path for immersive fly-through experiences.
npx shadcn add "https://www.terrae.dev/camera-follow.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightmapbox-gl
Registry dependencies
https://www.terrae.dev/map.json
Files1 · source cached 10/9/2026, 5:25:41 PM
src/registry/map/camera-follow.tsx
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import { createPortal } from "react-dom"
import type mapboxgl from "mapbox-gl"
import { mapgl } from "./map-library"
import { useMap } from "./hooks"
import type { MapPath } from "./types"
type MarkerState = {
position: [number, number]
bearing: number
}
type MapCameraFollowProps = {
path: MapPath
duration?: number
zoom?: number
pitch?: number
autoStart?: boolean
loop?: boolean
loopDelay?: number
marker?: boolean | ReactNode
markerSize?: number
onComplete?: () => void
}
const DEFAULT_DURATION = 20000
const DEFAULT_ZOOM = 14
const DEFAULT_PITCH = 60
const DEFAULT_LOOP_DELAY = 1000
const DEFAULT_MARKER_SIZE = 48
const calculateBearing = (from: [number, number], to: [number, number]): number => {
const dLon = ((to[0] - from[0]) * Math.PI) / 180
const lat1 = (from[1] * Math.PI) / 180
const lat2 = (to[1] * Math.PI) / 180
const y = Math.sin(dLon) * Math.cos(lat2)
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon)
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360
}
const interpolatePosition = (path: MapPath, progress: number): [number, number] => {
const totalSegments = path.length - 1
const segmentProgress = progress * totalSegments
const segmentIndex = Math.min(Math.floor(segmentProgress), totalSegments - 1)
const segmentFraction = segmentProgress - segmentIndex
const start = path[segmentIndex]
const end = path[segmentIndex + 1]
return [start[0] + (end[0] - start[0]) * segmentFraction, start[1] + (end[1] - start[1]) * segmentFraction]
}
const DefaultNavigationMarker = ({ size }: { size: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24 4L8 40L24 32L40 40L24 4Z" fill="#3b82f6" stroke="white" strokeWidth="2" strokeLinejoin="round" />
</svg>
)
}
export const MapCameraFollow = ({
path,
duration = DEFAULT_DURATION,
zoom = DEFAULT_ZOOM,
pitch = DEFAULT_PITCH,
autoStart = true,
loop = false,
loopDelay = DEFAULT_LOOP_DELAY,
marker,
markerSize = DEFAULT_MARKER_SIZE,
onComplete,
}: MapCameraFollowProps) => {
const { map, isLoaded } = useMap()
const animationFrameRef = useRef<number | undefined>(undefined)
const loopTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const startTimeRef = useRef(0)
const pausedProgressRef = useRef(0)
const markerRef = useRef<mapboxgl.Marker | null>(null)
const propsRef = useRef({ path, duration, zoom, pitch, onComplete, autoStart })
propsRef.current = { path, duration, zoom, pitch, onComplete, autoStart }
const [markerState, setMarkerState] = useState<MarkerState | null>(null)
const [markerElement, setMarkerElement] = useState<HTMLDivElement | null>(null)
useEffect(() => {
if (!map || !isLoaded || marker === undefined) {
return
}
const el = document.createElement("div")
el.style.display = "flex"
el.style.alignItems = "center"
el.style.justifyContent = "center"
const mapMarker = new mapgl.Marker({
element: el,
rotationAlignment: "map",
pitchAlignment: "map",
})
markerRef.current = mapMarker
setMarkerElement(el)
return () => {
mapMarker.remove()
markerRef.current = null
setMarkerElement(null)
}
}, [map, isLoaded, marker])
useEffect(() => {
if (!markerState || !markerRef.current || !map) {
return
}
markerRef.current.setLngLat(markerState.position).setRotation(markerState.bearing).addTo(map)
}, [map, markerState])
useEffect(() => {
if (!map || !isLoaded) {
return
}
const cancelAnimation = () => {
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = undefined
}
if (loopTimeoutRef.current) {
clearTimeout(loopTimeoutRef.current)
loopTimeoutRef.current = undefined
}
}
if (!autoStart) {
cancelAnimation()
return
}
if (propsRef.current.path.length < 2) {
return
}
startTimeRef.current = Date.now() - pausedProgressRef.current * propsRef.current.duration
const animate = () => {
if (!map || !propsRef.current.autoStart) {
cancelAnimation()
return
}
const { path, duration, zoom, pitch, onComplete } = propsRef.current
const elapsed = Date.now() - startTimeRef.current
const progress = Math.min(elapsed / duration, 1)
pausedProgressRef.current = progress
const currentPosition = interpolatePosition(path, progress)
const lookAheadPosition = interpolatePosition(path, Math.min(progress + 0.02, 1))
const bearing = calculateBearing(currentPosition, lookAheadPosition)
setMarkerState({ position: currentPosition, bearing })
try {
map.easeTo({
center: currentPosition,
bearing,
pitch,
zoom,
duration: 0,
})
} catch {
cancelAnimation()
return
}
if (progress < 1) {
animationFrameRef.current = requestAnimationFrame(animate)
return
}
pausedProgressRef.current = 0
onComplete?.()
if (loop) {
loopTimeoutRef.current = setTimeout(() => {
startTimeRef.current = Date.now()
animationFrameRef.current = requestAnimationFrame(animate)
}, loopDelay)
}
}
animationFrameRef.current = requestAnimationFrame(animate)
return cancelAnimation
}, [map, isLoaded, autoStart, loop, loopDelay])
if (marker !== undefined && markerElement) {
return createPortal(marker === true ? <DefaultNavigationMarker size={markerSize} /> : marker, markerElement)
}
return null
}
export const useCameraFollowControl = () => {
const [isPlaying, setIsPlaying] = useState(false)
const start = () => {
setIsPlaying(true)
}
const stop = () => {
setIsPlaying(false)
}
const toggle = () => {
setIsPlaying((prev) => !prev)
}
return { isPlaying, start, stop, toggle }
}