Skip to content

Building a Custom Widget

The full reference for authoring widgets — the manifest, surfaces, placement options, icons, and the shared chrome conventions. If you haven't yet, walk through Your First Widget first; this page assumes that loop.

Anatomy of a widget

A widget is a folder under app/widgets/. The shell only ever looks at one filewidget.config.ts, which must export const manifest. Everything else in the folder is yours to organize.

app/widgets/my-widget/
├── widget.config.ts   ← the manifest (the only file the shell reads)
├── Component.tsx       ← your React component
├── icon.svg            ← the launcher icon
└── …                   ← anything else you need

A folder whose name starts with _ is skipped by auto-discovery — that's how _template stays out of your app.

The manifest

export interface WidgetManifest {
  id: string
  label: string
  description?: string
  icon: string
  icons?: Partial<Record<IconSurface, string>>
  surfaces: WidgetSurface[]
  defaults?: WidgetDefaults
  component: ComponentType
}
Field Type Description
id string Globally-unique slug. A duplicate id throws at boot
label string Display name (panel header, tooltip, mobile carousel)
description string Optional tooltip text
icon string The launcher glyph (imported SVG)
icons per-surface map Optional per-surface icon overrides (below)
surfaces ('top-bar' \| 'left-panel' \| 'floating')[] Which surfaces the widget may be placed on
defaults WidgetDefaults Default placement options (below)
component ComponentType Your React component

defaults

defaults: { panelWidth: 320, hasUI: true, chromeless: false, stayOnMobile: false }
Field Type Default Description
panelWidth number 320 Width of the widget's panel (top-bar / left-panel surfaces)
size 'small' \| 'medium' \| 'large' 'small' A size hint
hasUI boolean true Whether the widget has a pop-out UI (set false for action-only floating widgets)
chromeless boolean false Floating only — render the pop-out without the shared header (the widget draws flush to the edges). Search uses this
stayOnMobile boolean false Floating only — keep the pop-out over the map on mobile instead of routing to the bottom sheet

Surfaces

A widget declares the surfaces it supports; you place it by adding its id to the matching array in settings.ts. Placing a widget on a surface it doesn't declare throws at boot.

Surface Placement array UI location
top-bar topBarWidgets A side panel
left-panel leftPanelWidgets A slide-out panel beside the map
floating floatingWidgets A pop-out over a map corner

Placement options

Each placement entry is a WidgetPlacement:

{ widgetName: 'my-widget', panelWidth: 360, header: { showTitle: false } }
Field Type Applies to Description
widgetName string all The widget's id
panelWidth number top-bar / left-panel Overrides the manifest default
size WidgetSize all Overrides the manifest default
header WidgetHeaderConfig top-bar / left-panel Per-placement header override (showTitle, showDivider, titleColor)
order number floating CSS order within the corner (below)
stayOnMobile boolean floating Overrides the manifest default

Floating placement

Floating widgets go in a slot keyed by corner:

floatingWidgets: [
  {
    slot: 'top-left',          // 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
    direction: 'vertical',     // stack direction (optional)
    spacing: 8,                // gap in px (optional)
    widgets: [{ widgetName: 'search', order: -1 }],
  },
],

MapLibre's native controls sit at order: 0, and it stacks corner items by source order with native controls first. So a negative order (e.g. -1) lifts your widget above the native controls in the same corner; a positive value pushes it below.

Connecting to the map

Use the hooks from @/core/hooks:

import { useMap, useMapReady } from '@/core/hooks'

const map = useMap()       // the MapLibre map (or null before it exists)
const ready = useMapReady() // true once the style has loaded

Always gate map work on ready. For shared state across widgets, read the Zustand store via useStore from @/core/store (see Internals).

Icons

The launcher icon is an imported SVG. Conventions (from the template):

  • Use a 24×24 viewBox, fill="none", and a heavy stroke.
  • Floating-slot icons (rendered on the white control over the map): stroke="#333333", stroke-width="2.5" to match MapLibre's native controls. A thinner/grayer stroke reads as washed-out next to them.
  • Themed top-bar / dark-surface icons: a light stroke (white) at stroke-width="2" so they show on the colored bar.

When one icon can't work on every surface, supply per-surface variants via icons:

icon,                                  // base
icons: {
  'left-panel': iconPanel,             // dark, for the white panel
  mobile: iconMobile,                  // white, for the colored mobile carousel
},

Color tokens

Use the semantic theme utilities for chrome so the whole app re-skins from settings.branding.colors:

Utility Role
bg-primary / text-primary brand / active / primary action
text-text / text-mutedText body / muted text
bg-surface "info box" gray (group/section headers)
bg-danger hover:bg-dangerHover destructive (delete/clear) buttons
bg-success hover:bg-successHover affirmative (done/confirm) buttons
bg-save hover:bg-saveHover Save buttons
disabled:bg-disabledBg disabled:text-disabledText disabled buttons

These work on every surface, including floating pop-outs — a global rule neutralizes MapLibre's button-hover style there, so you never need surface-specific CSS. (Map paint colors are the exception — MapLibre style values can't read CSS variables.)

For a standard list-row hover (tinted background + left accent), add the kit-row-hover class to the row.

Shared chrome

Don't hand-roll these — import the shared pieces so every widget looks consistent:

  • Action glyphsimport { TrashIcon, EditNameIcon, DownloadIcon, CloseIcon } from '@/core/icons'. TrashIcon = delete data; CloseIcon (the X) = dismiss. They use currentColor, so color them via the button's text color.
  • Confirmationsimport { ConfirmDialog } from '@/shell/ConfirmDialog' for "are you sure?" prompts. Render it as a child of your widget's root (give the root relative); it dims and blocks the widget until the user chooses. variant="danger" = red confirm; variant="default" = primary.

Widget-specific settings

If your widget needs author config, add a block to Settings (in app/src/core/types.ts) and read it via import { settings } from '@user/settings'. The built-in search / measure / draw / bookmarks widgets follow this pattern — see their pages under Widgets.