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 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¶
| 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:
| 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) atstroke-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 glyphs —
import { TrashIcon, EditNameIcon, DownloadIcon, CloseIcon } from '@/core/icons'.TrashIcon= delete data;CloseIcon(the X) = dismiss. They usecurrentColor, so color them via the button's text color. - Confirmations —
import { ConfirmDialog } from '@/shell/ConfirmDialog'for "are you sure?" prompts. Render it as a child of your widget's root (give the rootrelative); 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.