Skip to content

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:

app/widgets/_template/   →   app/widgets/zoom/

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:

"topBarWidgets": [
  { "widgetName": "zoom" }
]

5. Run it

npm run dev

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 dev every widget folder loads; npm run build bundles only the widgets a project places, so an unplaced widget costs the built app nothing.
  • The manifest told the shell the widget's id, icon, surface and component.
  • surfaces: ['top-bar'] in the manifest allowed you to place it in topBarWidgets.
  • 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.