Your First Widget¶
A five-minute, follow-along build. We'll make a tiny widget that shows the current zoom level in the top bar. By the end you'll understand the whole widget loop: folder → manifest → placement.
1. Copy the template¶
Every widget is a folder under app/widgets/. The starter lives at app/widgets/_template/ — the leading underscore keeps it out of auto-discovery. Copy it to a new name without the underscore:
You now have app/widgets/zoom/ with three files: widget.config.ts, Component.tsx, and icon.svg.
2. Write the component¶
Open app/widgets/zoom/Component.tsx and replace it with a component that reads the map and shows its zoom. The kit gives you hooks for the map instance:
import { useEffect, useState } from 'react'
import { useMap, useMapReady } from '@/core/hooks'
export function ZoomWidget() {
const map = useMap()
const ready = useMapReady()
const [zoom, setZoom] = useState(0)
useEffect(() => {
if (!map || !ready) return
const update = () => setZoom(map.getZoom())
update()
map.on('move', update)
return () => map.off('move', update)
}, [map, ready])
return (
<div className="p-4">
<h2 className="mb-1 text-base font-semibold text-text">Current zoom</h2>
<p className="text-2xl font-bold text-primary">{zoom.toFixed(2)}</p>
</div>
)
}
Two things to note:
useMap()/useMapReady()(from@/core/hooks) give you the MapLibre map once it exists. Always gate onready.- We use the theme tokens
text-textandtext-primaryso the widget re-skins with the rest of the app.
3. Update the manifest¶
Open app/widgets/zoom/widget.config.ts. Point it at your component and give it a unique id, label, and the surface you want:
import type { WidgetManifest } from '@/core/widget-types'
import { ZoomWidget } from './Component'
import icon from './icon.svg'
export const manifest: WidgetManifest = {
id: 'zoom',
label: 'Zoom',
description: 'Shows the current map zoom level.',
icon,
surfaces: ['top-bar'],
component: ZoomWidget,
}
The id must be unique across all widgets. The shell auto-discovers this folder — there's no registry to edit.
4. Place it¶
Open app/settings.ts and add your widget to the top bar:
5. Run it¶
A new icon appears in the top bar. Click it — a panel opens showing the live zoom, updating as you zoom the map.
What just happened¶
- The folder name (no underscore) made it discoverable.
- The
manifesttold the shell its id, icon, surface, and component. - The
surfaces: ['top-bar']declaration let you place it intopBarWidgets. useMap()connected your component to the live map.
That's the entire model. To go deeper — multiple surfaces, floating placement, per-surface icons, shared chrome (confirm dialogs, icons), and the full manifest reference — see Building a Custom Widget.