The @fyit/crouton-maps package provides seamless map integration for your Nuxt Crouton applications using Mapbox GL JS. Add interactive maps, geocoding, and location-based features to your app.
{
"name": "@fyit/crouton-maps",
"version": "0.1.0",
"status": "BETA"
}
Install the beta package:
pnpm add @fyit/crouton-maps
nuxt-mapbox (v1.6.4+) and mapbox-gl (v3.0.0+) under the hood.Add the layer to your nuxt.config.ts:
export default defineNuxtConfig({
extends: [
'@fyit/crouton',
'@fyit/crouton-maps' // Add maps layer
]
// No runtimeConfig needed — the layer auto-configures from env vars
})
Create or update your .env file:
# Server-side token (used by geocoding proxy, never sent to client)
MAPBOX_TOKEN=sk.eyJ1IjoieW91ciIsImEiOiJ0b2tlbiJ9...
# Optional: domain-restricted browser token for client-side tile loading
# Falls back to MAPBOX_TOKEN if not set (fine for local dev)
MAPBOX_PUBLIC_TOKEN=pk.eyJ1IjoieW91ciIsImEiOiJ0b2tlbiJ9...
The layer automatically reads MAPBOX_TOKEN and MAPBOX_PUBLIC_TOKEN from your environment and configures both private (server-side geocoding) and public (client-side tile loading) runtime config. No manual runtimeConfig setup is required.
To customize default map style, center, or zoom, you can optionally override in your nuxt.config.ts:
runtimeConfig: {
public: {
mapbox: {
style: 'mapbox://styles/mapbox/dark-v11',
center: [4.9041, 52.3676], // [lng, lat]
zoom: 10
}
}
}
.env file to version control. Add it to your .gitignore. For production, use a domain-restricted browser key for MAPBOX_PUBLIC_TOKEN.Interactive map component with loading states and error handling.
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | auto-generated | Map container ID |
center | [number, number] | From config | Initial center coordinates [lng, lat] |
zoom | number | 12 | Initial zoom level (0-22) |
style | string | streets-v12 | Mapbox style URL or preset |
height | string | '400px' | Container height (CSS value) |
width | string | '100%' | Container width (CSS value) |
class | string | - | Additional CSS classes |
flyToOnCenterChange | boolean | false | Animate center changes |
flyToDuration | number | 800 | Animation duration (ms) |
flyToEasing | function | easeInOutCubic | Easing function |
| Event | Payload | Description |
|---|---|---|
@load | map: Map | Map instance loaded and ready |
@error | error: string | Map failed to load |
| Slot | Props | Description |
|---|---|---|
default | { map: Map } | Scoped slot for markers/popups |
<script setup lang="ts">
const center = ref<[number, number]>([-122.4194, 37.7749])
const zoom = ref(12)
const handleMapLoad = (map) => {
console.log('Map loaded:', map)
}
</script>
<template>
<CroutonMapsMap
:center="center"
:zoom="zoom"
height="500px"
@load="handleMapLoad"
>
<template #default="{ map }">
<!-- Add markers here -->
</template>
</CroutonMapsMap>
</template>
Map marker with optional popup and smooth animations.
| Prop | Type | Required | Description |
|---|---|---|---|
map | Map | ✅ | Map instance from parent |
position | [number, number] | ✅ | Marker position [lng, lat] |
color | string | - | Marker color (hex/rgb/named) |
options | MarkerOptions | - | Mapbox marker options |
popupText | string | - | Plain text content for popup (rendered as text, not HTML) |
animateTransitions | boolean | undefined | Smooth position animation (effectively true; only disabled when explicitly false) |
animationDuration | number | 800 | Animation duration (ms) |
animationEasing | string | function | 'easeInOutCubic' | Easing preset or function |
| Event | Description |
|---|---|
@click | Marker clicked |
@dragStart | Drag started (requires draggable: true) |
@drag | Dragging in progress |
@dragEnd | Drag ended |
<template>
<CroutonMapsMap :center="center" :zoom="12" height="500px">
<template #default="{ map }">
<CroutonMapsMarker
:map="map"
:position="[-122.4194, 37.7749]"
color="#ef4444"
popup-text="San Francisco"
@click="handleMarkerClick"
/>
</template>
</CroutonMapsMap>
</template>
<script setup lang="ts">
const markerPosition = ref<[number, number]>([-122.4194, 37.7749])
const handleDragEnd = () => {
console.log('New position:', markerPosition.value)
}
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="markerPosition"
:options="{ draggable: true }"
@dragEnd="handleDragEnd"
/>
</template>
Standalone popup component for custom content.
| Prop | Type | Default | Description |
|---|---|---|---|
map | Map | - | Map instance (required) |
position | [number, number] | - | Popup position (required) |
options | PopupOptions | - | Mapbox popup options |
closeButton | boolean | true | Show close button |
closeOnClick | boolean | true | Close on map click |
maxWidth | string | '240px' | Maximum width |
| Event | Description |
|---|---|
@open | Popup opened |
@close | Popup closed |
| Slot | Description |
|---|---|
default | Popup content (supports Vue components) |
<template>
<CroutonMapsMap :center="center" :zoom="12" height="500px">
<template #default="{ map }">
<CroutonMapsPopup
:map="map"
:position="[-122.4194, 37.7749]"
max-width="300px"
>
<div class="p-4">
<h3 class="font-bold">Custom Popup</h3>
<p>Vue components work here!</p>
<UButton size="xs">Action</UButton>
</div>
</CroutonMapsPopup>
</template>
</CroutonMapsMap>
</template>
Compact map preview with modal expansion - perfect for forms and cards.
| Prop | Type | Description |
|---|---|---|
location | string | [number, number] | Location as coordinates or JSON string |
<script setup lang="ts">
const location = ref<[number, number]>([-122.4194, 37.7749])
// Or from a collection field
// const location = ref(item.coordinates) // "[lon, lat]" string
</script>
<template>
<div class="space-y-4">
<!-- Compact preview with click-to-expand -->
<CroutonMapsPreview :location="location" />
</div>
</template>
Access runtime configuration for Mapbox.
const {
accessToken, // string
style, // string
center, // [number, number] | undefined
zoom // number
} = useMapConfig()
<script setup lang="ts">
const config = useMapConfig()
console.log('Using style:', config.style)
console.log('Default center:', config.center)
</script>
Forward and reverse geocoding with Mapbox API.
const {
geocode, // (query: string) => Promise<GeocodeResult | null>
reverseGeocode, // (coords: [number, number]) => Promise<GeocodeResult | null>
loading, // Readonly<Ref<boolean>>
error // Readonly<Ref<string | null>>
} = useGeocode()
<script setup lang="ts">
const { geocode, loading, error } = useGeocode()
const address = ref('1600 Amphitheatre Parkway, Mountain View, CA')
const result = ref<GeocodeResult | null>(null)
const searchAddress = async () => {
result.value = await geocode(address.value)
if (result.value) {
console.log('Coordinates:', result.value.coordinates)
console.log('Full address:', result.value.address)
}
}
</script>
<template>
<div class="space-y-4">
<UInput
v-model="address"
placeholder="Enter an address"
/>
<UButton @click="searchAddress" :loading="loading">
Search
</UButton>
<div v-if="result" class="p-4 bg-gray-50 rounded">
<p class="font-semibold">{{ result.address }}</p>
<p class="text-sm text-gray-600">
{{ result.coordinates[0] }}, {{ result.coordinates[1] }}
</p>
</div>
<div v-if="error" class="text-red-500">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
const { reverseGeocode } = useGeocode()
const coordinates = ref<[number, number]>([-122.4194, 37.7749])
const address = ref('')
const findAddress = async () => {
const result = await reverseGeocode(coordinates.value)
if (result) {
address.value = result.address
}
}
</script>
interface GeocodeResult {
coordinates: [number, number]
address: string
placeName: string
context?: {
postcode?: string
place?: string
region?: string
country?: string
}
}
Named exports for working with Mapbox style presets. Import MAPBOX_STYLES (a const object of all style URLs) and getMapboxStyle() (a function that resolves a preset name or returns a custom URL as-is).
import { MAPBOX_STYLES, getMapboxStyle } from '#imports'
// Access style URLs directly
MAPBOX_STYLES.dark // 'mapbox://styles/mapbox/dark-v11'
// Resolve a preset name or pass through a custom URL
getMapboxStyle('dark') // 'mapbox://styles/mapbox/dark-v11'
getMapboxStyle('mapbox://styles/username/custom') // returns as-is
| Preset | URL | Description |
|---|---|---|
standard | mapbox://styles/mapbox/standard | New 3D style (recommended) |
streets | mapbox://styles/mapbox/streets-v12 | Classic street map |
outdoors | mapbox://styles/mapbox/outdoors-v12 | Outdoor/hiking map |
light | mapbox://styles/mapbox/light-v11 | Light theme |
dark | mapbox://styles/mapbox/dark-v11 | Dark theme |
satellite | mapbox://styles/mapbox/satellite-v9 | Satellite imagery |
satelliteStreets | mapbox://styles/mapbox/satellite-streets-v12 | Satellite + streets |
navigationDay | mapbox://styles/mapbox/navigation-day-v1 | Navigation (day) |
navigationNight | mapbox://styles/mapbox/navigation-night-v1 | Navigation (night) |
<script setup lang="ts">
import { getMapboxStyle } from '#imports'
const selectedStyle = ref('dark')
const mapStyle = computed(() => getMapboxStyle(selectedStyle.value))
</script>
<template>
<div>
<select v-model="selectedStyle">
<option value="streets">Streets</option>
<option value="dark">Dark</option>
<option value="satellite">Satellite</option>
</select>
<CroutonMapsMap
:style="mapStyle"
:center="[-122.4194, 37.7749]"
:zoom="12"
height="500px"
/>
</div>
</template>
Automatically sync marker colors with your Nuxt UI theme.
const markerColor = useMarkerColor() // Ref<string> (hex color)
<script setup lang="ts">
const markerColor = useMarkerColor()
// Automatically uses --ui-primary from your theme
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="position"
:color="markerColor"
/>
</template>
Combine geocoding with animated map navigation:
Search with geocoding:
<script setup lang="ts">
const { geocode, loading } = useGeocode()
const searchQuery = ref('')
const center = ref<[number, number]>([-122.4194, 37.7749])
const handleSearch = async () => {
const result = await geocode(searchQuery.value)
if (result) {
center.value = result.coordinates
}
}
</script>
<template>
<div class="space-y-4">
<div class="flex gap-2">
<UInput
v-model="searchQuery"
placeholder="Search for a place..."
class="flex-1"
@keyup.enter="handleSearch"
/>
<UButton @click="handleSearch" :loading="loading">Search</UButton>
</div>
<CroutonMapsMap :center="center" :zoom="12" height="500px">
<template #default="{ map }">
<CroutonMapsMarker :map="map" :position="center" color="#ef4444" />
</template>
</CroutonMapsMap>
</div>
</template>
With smooth animations:
<script setup lang="ts">
const mapInstance = ref(null)
const handleSearch = async () => {
const result = await geocode(searchQuery.value)
if (result && mapInstance.value) {
mapInstance.value.flyTo({
center: result.coordinates,
zoom: 14,
duration: 2000
})
}
}
</script>
<template>
<CroutonMapsMap @load="(map) => mapInstance = map">
<!-- Map content -->
</CroutonMapsMap>
</template>
Display collection items on a map:
Store list:
<script setup lang="ts">
const { items: stores } = useCollection('stores')
const selectedStore = ref(null)
</script>
<template>
<div class="space-y-4">
<div
v-for="store in stores"
:key="store.id"
class="p-4 border rounded hover:bg-gray-50"
:class="{ 'bg-blue-50': selectedStore?.id === store.id }"
>
<h3 class="font-bold">{{ store.name }}</h3>
<p class="text-sm text-gray-600">{{ store.address }}</p>
</div>
</div>
</template>
Map with multiple markers:
<script setup lang="ts">
const { items: stores } = useCollection('stores')
const storeMarkers = computed(() =>
stores.value.map(store => ({
id: store.id,
position: [store.longitude, store.latitude],
name: store.name,
address: store.address
}))
)
const handleMarkerClick = (store) => {
selectedStore.value = store
}
</script>
<template>
<CroutonMapsMap :center="[-120, 37]" :zoom="6" height="600px">
<template #default="{ map }">
<CroutonMapsMarker
v-for="marker in storeMarkers"
:key="marker.id"
:map="map"
:position="marker.position"
:popup-text="marker.name"
@click="handleMarkerClick(marker)"
/>
</template>
</CroutonMapsMap>
</template>
Add location tracking to your collections:
<script setup lang="ts">
const { items } = useCollection('events')
// Assume your collection has latitude/longitude fields
const eventMarkers = computed(() =>
items.value
.filter(event => event.latitude && event.longitude)
.map(event => ({
id: event.id,
position: [event.longitude, event.latitude],
title: event.title,
date: event.date
}))
)
</script>
<template>
<CroutonMapsMap :center="[-120, 37]" :zoom="6" height="600px">
<template #default="{ map }">
<CroutonMapsMarker
v-for="marker in eventMarkers"
:key="marker.id"
:map="map"
:position="marker.position"
:popup-text="marker.title"
/>
</template>
</CroutonMapsMap>
</template>
All types are exported from the package:
import type {
// Main types
MapConfig,
MapInstance,
PopupInstance,
// Options
UseMapOptions,
// Results
GeocodeResult,
// Animations
EasingFunction,
EasingPreset,
MarkerAnimationOptions,
MapFlyToOptions,
// Style presets
MapboxStylePreset,
// Re-exported Mapbox GL types
Map,
Marker,
Popup,
MapOptions,
MarkerOptions,
PopupOptions,
LngLatLike
} from '@fyit/crouton-maps'
Both maps and markers support smooth animations.
<script setup lang="ts">
const center = ref<[number, number]>([-122.4194, 37.7749])
const goToNewYork = () => {
center.value = [-74.0060, 40.7128]
}
</script>
<template>
<CroutonMapsMap
:center="center"
:zoom="12"
height="500px"
:flyToOnCenterChange="true"
:flyToDuration="1500"
/>
</template>
<script setup lang="ts">
const position = ref<[number, number]>([-122.4194, 37.7749])
// Position updates will animate smoothly
const moveMarker = () => {
position.value = [-122.4084, 37.7749]
}
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="position"
:animateTransitions="true"
:animationDuration="1000"
animationEasing="easeInOutCubic"
/>
</template>
<script setup lang="ts">
// Bounce easing
const bounceEasing = (t: number): number => {
return t < 0.5
? 8 * t * t * t * t
: 1 - 8 * (--t) * t * t * t
}
</script>
<template>
<CroutonMapsMarker
:map="map"
:position="position"
:animationEasing="bounceEasing"
/>
</template>
<script setup lang="ts">
// Instead of rendering all markers
const visibleMarkers = computed(() => {
// Filter markers based on map bounds
return allMarkers.value.filter(marker => {
// Check if marker is in viewport
return isInBounds(marker.position)
})
})
</script>
Always handle geocoding errors gracefully:
<script setup lang="ts">
const { geocode, error } = useGeocode()
const search = async (query: string) => {
const result = await geocode(query)
if (error.value) {
// Show user-friendly error
toast.add({
title: 'Location not found',
description: 'Please try a different search term',
color: 'red'
})
return
}
if (!result) {
// No results found
toast.add({
title: 'No results',
description: 'Try being more specific',
color: 'amber'
})
}
}
</script>
Monitor your Mapbox usage:
<script setup lang="ts">
// Debounce geocoding requests
import { useDebounceFn } from '@vueuse/core'
const { geocode } = useGeocode()
const debouncedGeocode = useDebounceFn(async (query: string) => {
await geocode(query)
}, 500) // Wait 500ms after user stops typing
</script>
Add proper labels and ARIA attributes:
<template>
<div role="region" aria-label="Interactive map">
<CroutonMapsMap
:center="center"
:zoom="12"
height="500px"
>
<template #default="{ map }">
<CroutonMapsMarker
v-for="location in locations"
:key="location.id"
:map="map"
:position="location.position"
:aria-label="`Location: ${location.name}`"
/>
</template>
</CroutonMapsMap>
</div>
</template>
Symptoms: Blank container or loading spinner never disappears.
Solutions:
.env:echo $MAPBOX_TOKEN
const config = useMapConfig()
console.log('Has token:', !!config.accessToken)
<CroutonMapsMap height="500px" /> <!-- ✅ Good -->
<CroutonMapsMap /> <!-- ❌ Bad - no height -->
Symptoms: useGeocode() returns null or errors.
Solutions:
// ✅ Good
await geocode('123 Main St, San Francisco, CA')
// ❌ Bad
await geocode('') // Empty query
Symptoms: Type errors with Mapbox GL types.
Solutions:
pnpm add -D mapbox-gl
import type { LngLatLike } from '@fyit/crouton-maps'
Symptoms: Slow rendering with many markers.
Solutions:
<script setup lang="ts">
const MapComponent = defineAsyncComponent(() =>
import('#components').then(c => c.CroutonMapsMap)
)
</script>
Planned features for future releases: