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 file — widget.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 shape overrides (below) — rarely needed
surfaces ('top-bar' \| 'side-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 / side-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
side-panel sidePanelWidgets 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 / side-panel Overrides the manifest default
size WidgetSize all Overrides the manifest default
header WidgetHeaderConfig top-bar / side-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. You only need one file, and its color doesn't matter. The shell draws every launcher icon as a shape and fills it with the right theme color for wherever it appears:

Where Color
Floating button over the map text (softened slightly in dark presets to match MapLibre's own buttons)
Side panel mutedText
Top bar and mobile carousel topBarText

So the same file works on every surface and in every preset. Conventions (from the template):

  • Use a 24×24 viewBox and stroke-width="2" — that matches the weight of MapLibre's zoom/compass buttons beside a floating icon. Solid fills work too.
  • Keep stroke-linecap / stroke-linejoin round like the built-ins.
  • Draw it in any single color. To shade parts of an icon, use fill-opacity rather than different colors — different opaque colors all come out as the same solid shape.

icons still exists for the rare case where a surface needs a genuinely different shape — e.g. a simpler glyph for the small mobile-carousel button. Never use it for color:

icon,                                  // used everywhere by default
icons: {
  mobile: iconMobile,                  // a simpler shape for the mobile carousel
},

Color tokens

Use the semantic theme utilities for your widget's chrome so it re-skins with the rest of the app from app/theme.ts — dark presets included. Avoid raw colors like bg-white, text-slate-900, or border-gray-200: they'll stay light when the user picks a dark preset.

Utility Role
bg-panelBg / border-panelBorder widget background, borders, and dividers
text-text / text-mutedText headings and body / secondary text
bg-surface "info box" fill (section headers, chips)
bg-primary / text-primary brand / active / primary action
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
bg-actionActive / bg-actionNotActive (+ Hover) selected / idle tool buttons
bg-slider toggle-switch "on" / range accent
disabled:bg-disabledBg disabled:text-disabledText disabled buttons
hover:bg-mutedText/10 a generic hover tint (no token needed)
bg-topBarBg / text-topBarText only for UI that sits on the top bar

The full token list, with defaults, is on the Theme page.

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.

Styling with a plain .css file (like the built-in Draw and Measure widgets)? Use var(--color-<token>) — e.g. background: var(--color-panelBg) — never a hex value. For the rare thing a token can't express (such as an icon baked into a data URI), target the dark presets with [data-theme='dark'] .my-widget …. app/widgets/draw/draw.css and measure/measure.css are worked examples.

Colors painted on the map are the exception — MapLibre layer styles can't read CSS variables.

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

For a small scrolling box, add kit-scrollbar to show a thin scrollbar at all times — macOS (Safari especially) otherwise hides it until you scroll, so the box doesn't look scrollable.

Shared chrome

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

  • Action glyphs — import { 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.
  • Toggles — import { ToggleSwitch } from '@/shell/ToggleSwitch' for on/off settings: <ToggleSwitch checked={on} onChange={setOn} ariaLabel="Show totals" />. It has no text of its own, so pair it with a label (and always pass ariaLabel). Colored by the slider token when on.
  • Confirmations — import { 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.