Your First Widget¶
A five-minute walkthrough. You'll build a small widget that shows the current zoom level in the top bar, and along the way see every step a widget goes through: folder, manifest, placement.
1. Copy the template¶
Every widget is a folder under app/widgets/. The starter is app/widgets/_template/; the leading underscore keeps the shell from loading it. Copy it to a new name without the underscore:
You now have app/widgets/zoom/ with the standard widget layout: widget.config.ts and Widget.tsx at the top, the launcher icon in assets/icon.svg, and empty components/ and lib/ folders for later.
2. Write the component¶
Replace app/widgets/zoom/Widget.tsx with a component that reads the map's zoom. The kit has hooks that give you the map:
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>
)
}
useMap() and useMapReady() from @/core/hooks give you the MapLibre map once it exists; always wait for ready before using it. The classes text-text and text-primary are theme colors, so the widget follows the app's theme.
3. Update the manifest¶
Open app/widgets/zoom/widget.config.ts. Point it at your component and give it a unique id, a label and the surface you want:
import type { WidgetManifest } from '@/core/widget-types'
import { ZoomWidget } from './Widget'
import icon from './assets/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 match the folder name (zoom here); the shell checks this and stops with an error if they differ, which also keeps ids unique. The shell finds the folder on its own; there's no list of widgets to edit.
4. Place it¶
Open your project's settings.json and add the widget to the top bar, next to any widgets already there:
5. Run it¶
A new icon appears in the top bar. Click it to open a panel showing the zoom, which updates as you zoom the map.
What just happened¶
- Because the folder name has no underscore, the shell loaded it. In
npm run devevery widget folder loads;npm run buildbundles only the widgets a project places, so an unplaced widget costs the built app nothing. - The
manifesttold the shell the widget's id, icon, surface and component. surfaces: ['top-bar']in the manifest allowed you to place it intopBarWidgets.useMap()gave your component the live map.
Building a Custom Widget covers the rest: the folder layout, multiple surfaces, floating placement, icons, the shared @/ui components, and the full manifest reference. In the repo, app/widgets/WIDGETS.md sums all of it up on one page.