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¶
| 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:
| 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-linejoinroundlike the built-ins. - Draw it in any single color. To shade parts of an icon, use
fill-opacityrather 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 usecurrentColor, 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 passariaLabel). Colored by theslidertoken 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 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.