Skip to content

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:

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

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 on ready.
  • We use the theme tokens text-text and text-primary so 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:

topBarWidgets: [
  { widgetName: 'zoom' },
  // … your other widgets
],

5. Run it

npm run dev

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 manifest told the shell its id, icon, surface, and component.
  • The surfaces: ['top-bar'] declaration let you place it in topBarWidgets.
  • 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.