Skip to content

Building a Custom Widget

The reference for writing widgets: the manifest, surfaces, placement options, icons, colors and the shared components. It assumes you've done Your First Widget. The repo has a shorter version of this page at app/widgets/WIDGETS.md, written for people and AI assistants working in the code.

Anatomy of a widget

A widget is a folder under app/widgets/. The shell only reads one file, widget.config.ts, which must export const manifest. Every built-in widget lays out the rest the same way, and yours should too, so anyone opening a widget folder knows where things are:

app/widgets/my-widget/
├── widget.config.ts   ← the manifest (the only file the shell reads)
├── Widget.tsx         ← the component the manifest points at
├── components/        ← sub-UI used by Widget.tsx
├── lib/               ← non-UI logic: controllers, storage, unit math, map style specs
└── assets/            ← icon.svg, images, CSS

Only widget.config.ts and Widget.tsx sit at the root. Add components/, lib/ or assets/ when you have something to put in them, using those names. The component inside Widget.tsx keeps a descriptive name, such as export function LayerListWidget(), so React DevTools and error messages can tell widgets apart.

The shell skips folders whose name starts with _. That's how _template stays out of your app. A widget's folder name must match its manifest id; the shell stops with an error if they differ.

Widgets are shared by every project. npm run dev loads all of them, so a newly placed widget works at once. npm run build bundles only the widgets the project places (topBarWidgets, sidePanelWidgets, floatingWidgets, mobileWidgets), with their components, CSS and libraries, and stops if a placed name has no widget folder. The build log lists what was bundled and left out.

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 Unique id. Two widgets with the same id stop the app at boot
label string Name in the panel header, tooltip and mobile carousel
description string Optional tooltip text
icon string The launcher icon (an imported SVG)
icons per-surface map A different icon shape for one surface (below). Rarely needed
surfaces ('top-bar' \| 'side-panel' \| 'floating')[] Surfaces the widget can be placed on
defaults WidgetDefaults Default placement options (below)
component ComponentType Your React component

defaults

defaults: { panelWidth: 320, hasUI: true, chromeless: false, stayOnMobile: false, opens: 'sideways' }
Field Type Default Description
panelWidth number 320 Panel width, on the top bar and in floating pop-outs. It doesn't apply in the side rail, whose width is ui.layout.sideRailPanelWidth
hasUI boolean true Whether the widget opens a panel. Set false for floating buttons that only perform an action
chromeless boolean false Floating only. No title bar; the widget fills its pop-out. Search uses this
stayOnMobile boolean false Floating only. On phones, keep the widget's button over the map and open it there, instead of in the bottom sheet
opens 'sideways' \| 'vertically' by corner Floating only. Which way the pop-out opens from its button: toward the middle of the map, or straight down (top corners) / up (bottom corners). Unset: sideways in a vertical corner, vertically in a horizontal one. Search sets sideways, since its bar is horizontal

Surfaces

A widget lists the surfaces it supports, and you place it by adding its id to the matching array in settings.json. Placing a widget on a surface it doesn't list stops the app at boot.

Surface Placement array UI location
top-bar topBarWidgets A panel beside the map, opposite the side rail
side-panel sidePanelWidgets A slide-out panel beside the map
floating floatingWidgets A pop-out over a map corner

Placement options

Each placement 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 / floating Overrides the manifest default. Ignored in the side rail
header object top-bar / side-panel / floating Title bar settings for this placement (showTitle, showDivider, titleColor). On the side rail, "showTitle": false hides the panel's whole header bar
order number floating Position within the corner (below)
stayOnMobile boolean floating Overrides the manifest default
opens "sideways" \| "vertically" floating Overrides the manifest default (see defaults above)

Floating placement

Floating widgets go in a slot named for its corner:

"floatingWidgets": [
  {
    "slot": "top-left",
    "widgets": [{ "widgetName": "search", "order": -1 }]
  },
  {
    "slot": "top-right",
    "direction": "horizontal",
    "widgets": [{ "widgetName": "coordinates" }, { "widgetName": "legend" }]
  }
]

slot is one of "top-left", "top-right", "bottom-left", "bottom-right". direction is "vertical" (the default) or "horizontal".

MapLibre adds its own controls to a corner first, at order: 0. A negative order (e.g. -1) puts your widget above them, and a positive one puts it below.

direction: 'horizontal' lays that slot's widgets out in one row, 10px apart (the gap MapLibre's controls use). Each widget's order sorts it within the row, and the row's lowest order places the whole row among the native controls, so -1 still lifts it above them. Pop-outs in a horizontal row open below the button in top corners and above it in bottom corners, unless the widget or placement sets opens. Two slot entries for the same corner stack as separate blocks.

Connecting to the map

Use the hooks in @/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

Wait for ready before touching the map. For state shared between widgets, read the Zustand store with useStore from @/core/store (see Internals).

Sources and layers you add stay through a basemap switch, since a switch only swaps the basemap's own layers. Never call map.setStyle() yourself: it would remove every data layer. Switch basemaps with switchBasemap() from @/core/basemapSwitch.

If your widget uses a large library, load it with import() the first time it's needed (const { thing } = await import('big-library')), so it stays out of the startup bundle. DuckDB, esri-gl, FlatGeobuf and the info modal's markdown renderer load this way.

Using map clicks

If your widget takes clicks on the map (drawing, measuring, picking a point), claim them while the tool is in use:

import { useMapClickLock } from '@/core/hooks'

useMapClickLock('my-widget', toolActive)

While any widget holds a claim, popups stay off: no click or hover popups, and no pointer cursor over features. Set your own map cursor while you hold it. Claim only while a tool is out or a feature is being edited, not just because the widget is open. Draw, Measure and Coordinates all work this way.

Icons

The launcher icon is an imported SVG. One file is enough, and its color doesn't matter: the shell uses the icon's shape and fills it with the right theme color for where it appears.

Where Color
Floating button over the map mapControlIcon (set by the theme's mapControls, to match MapLibre's buttons)
Side panel panelWidget
Top bar and mobile carousel topBarWidget

So the same file works on every surface and in every preset. Guidelines:

  • Use a 24×24 viewBox and stroke-width="2", which matches MapLibre's zoom and compass buttons next to a floating icon. Solid fills work too.
  • Use round stroke-linecap and stroke-linejoin, like the built-in icons.
  • Draw in one color. To shade part of an icon, use fill-opacity; different solid colors all come out as the same flat shape.

icons is for the rare surface that needs a different shape, such as a simpler icon for the small mobile carousel button. Don't use it for color:

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

Color tokens

Use the theme classes for your widget's colors so it follows your project's theme.json, dark presets included. Fixed colors like bg-white, text-slate-900 or border-gray-200 stay light when someone picks a dark preset.

Class Use for
bg-panelBg / border-panelBorder Widget background, borders and dividers
text-text / text-strongText Body text / names, titles and emphasized text
bg-surface Section headers, chips and info boxes
bg-layerGroupBg Group header rows, like the layer list's
bg-primary / text-primary Brand color, active states, main actions
bg-danger hover:bg-dangerHover Delete and clear buttons
bg-success hover:bg-successHover Done and confirm buttons
bg-save hover:bg-saveHover Save buttons
bg-actionActive / bg-actionNotActive (+ Hover) Selected and idle tool buttons
bg-slider Toggle switches when on, range sliders
disabled:bg-disabledBg disabled:text-disabledText Disabled buttons
hover:bg-text/10 A general hover tint (no token needed)
bg-topBarBg / text-topBarWidget Only for UI that sits on the top bar

The Theme page lists every token with its default.

These classes work on every surface, floating pop-outs included. A global rule removes MapLibre's own button hover style there, so you don't need CSS per surface.

If you style with a plain .css file, as the Draw and Measure widgets do, use var(--color-<token>), e.g. background: var(--color-panelBg), and never a hex value. For the rare thing a token can't reach, like an icon inside a data URI, target dark presets with [data-theme='dark'] .my-widget …. app/widgets/draw/assets/draw.css and measure/assets/measure.css are working examples.

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

For the standard list row hover (a tinted background), use ItemRow (below), or add the kit-row-hover class to a row of your own.

For a small scrolling box, add kit-scrollbar to keep a thin scrollbar visible. macOS, Safari especially, hides scrollbars until you scroll, so the box otherwise doesn't look scrollable.

For text, use only the kit's sizes, which follow the project's ui.textSizes and the user's browser font size:

Class Default Use for
text-small 11px Fine print, badges
text-medium 12px Captions, secondary text, item names in long lists
text-large 14px Inputs, list rows, group names, longer reading
text-title 14px Titles inside the widget
text-button 14px Buttons (ActionButton and ToolButtonGrid already use it)
text-dialogButton 12px Cancel and confirm buttons in dialogs

Never Tailwind's own sizes (text-xs, text-sm, …) or px sizes like text-[11px]: they ignore the settings, and scripts/text-sizes.test.ts fails on them. In a .css file, write font-size: var(--text-medium) and so on, write control sizes and spacing in rem (px ÷ 16), and keep px for borders. Give text a color class too (text-text, text-strongText, …); text without one falls back to text.

Shared components: @/ui

The kit ships a small UI library in app/src/ui/, imported as @/ui/.... The built-in widgets are made from it; use it instead of building your own so every widget looks and behaves the same. Everything uses the theme tokens and works on every surface.

Import What it is
@/ui/icons In-widget glyphs: TrashIcon (deletes data), CloseIcon (the X, dismisses), EditNameIcon (rename), DownloadIcon, CopyIcon, CheckIcon, ChevronIcon (direction="right" \| "down" \| "left"), EyeIcon / EyeOffIcon, CheckboxIcon (state), FilterIcon, KebabIcon, ZoomToExtentIcon, ExternalLinkIcon. They use currentColor, so color them through the button's text color
@/ui/IconButton An icon-only button. label becomes its aria-label and tooltip; variant is default, danger or primary
@/ui/ActionButton A labeled button, with an icon before the text if you want one. variant is danger (Clear All), save, success or neutral (Download, Copy)
@/ui/ToolButtonGrid A two-column grid of map tools with an active state, as in Draw and Measure
@/ui/ItemRow ItemRow and ItemRowLabel: one row of an item list with the standard hover, an optional color dot, and room for IconButtons. active keeps the highlight on
@/ui/RenameInput Inline rename inside a row. Enter or clicking away saves, Escape cancels, and an empty name cancels
@/ui/EmptyState The placeholder for an empty list: a title and an optional hint
@/ui/Spinner A small loading spinner in theme colors (size in px). Pair it with text or an aria-busy parent
@/ui/InfoSection A collapsed "Info" box at the top of a widget explaining how to use it. A project can hide it with ui.hideWidgetInfo; it knows its widget through WidgetIdContext, which the shell sets around every widget, so there's nothing to pass
@/ui/ChoiceDialog A "pick one" pop-up with the same look and rules as ConfirmDialog: a title, an optional message, and a list of choices with descriptions. Draw and the layer list use it for download formats
@/ui/ConfirmDialog "Are you sure?" prompts. Render it inside your widget's root element and give that root relative; it dims and blocks the widget until the user chooses. variant="danger" makes the confirm button red, variant="default" uses the brand color
@/ui/ToggleSwitch An on/off switch: <ToggleSwitch checked={on} onChange={setOn} ariaLabel="Show totals" />. It has no text of its own, so put a label next to it and always pass ariaLabel
@/ui/useCopyToClipboard const { copied, copy } = useCopyToClipboard(). copy(text, key?) copies and sets copied to key for about a second, so one widget can tell its copy buttons apart
@/ui/useSavedItems A named list saved in the browser, as used by Bookmarks and Draw's Saved Drawings: add, replace (overwrite by name), rename, remove, findByName, and an optional maxCount. At the cap, full is true and add saves nothing and returns false, so your widget can say the list is full; replace still works. saveFailed is true when the last change didn't reach storage (full or blocked), so you can warn the user. Your widget shows its own overwrite and delete prompts

The Bookmarks widget is a compact example of several of these together.

Map helpers: @/core

Two modules in app/src/core/ cover what Draw and Measure share. Use them in any widget that draws on the map.

Import What it is
@/core/persistentOverlay Layers that stay on the map after your widget closes. removeOverlay(map, { layers, sources }) removes them. onSourcesLoaded(map, sourceIds, fn) runs fn once those sources have drawn: add the new layers first and remove the old ones in fn, so swapping between your live and kept layers doesn't blink
@/core/mapboxDraw For widgets built on mapbox-gl-draw. addMapboxDraw(map, styles) adds it with the MapLibre fixes and without its built-in buttons, and removeMapboxDraw takes it off again. Only one mapbox-gl-draw can be on the map, so on close hand your swap to swapOverlayIn(map, sources, removeLive), and on open to swapOverlayOut(map, removeOverlay); call finishPendingSwap() first thing on close. drawLayerIds(map, styles) lists the layers it renders; pass them as layers to queryRenderedFeatures so a hover check skips the basemap. drawEvents(map).on('draw.create', …) subscribes to Draw's own events (MapLibre 6 only types its own event names). setDrawVertexColor(map, vertexStyleId, color) colors the vertex handles, setDrawHover(draw, from, to) moves a hover highlight, repaintDraw(draw) repaints after setFeatureProperty, and featuresWithMeta(draw, meta) lists the features your widget tracks. DraftFeature colors the shape being drawn and dots its placed vertices. HOVER_COLOR is the map highlight color

If a widget crashes

A widget that throws while rendering only breaks its own pane. The pane shows "This widget couldn't load" and the error goes to the browser console. The map and the other widgets keep working, and opening the widget again tries again. Errors in click handlers or async code don't reach this; they only log to the console.

Widget-specific settings

If your widget needs settings, give it a block in settings.json. Every setting is defined once as a Zod schema in app/src/core/schema/, and that one definition gives the TypeScript type, the check when the files are read, and the hover help in the JSON. The built-in search, measure, draw and bookmarks widgets work this way; see their pages under Widgets.

  1. Write the block's schema in app/src/core/schema/widgets.ts. Make every field .optional() and describe it with .meta(): a description, the default (or defaultText for one in words), and a see link to your docs.
  2. Add it to settingsFileSchema in app/src/core/schema/settings.ts, and export its type from app/src/core/types.ts like the others.
  3. Read it with import { settings } from '@user/settings', and apply the default yourself (settings.bookmarks?.maxCount ?? 35): the schema only records it.
export const bookmarksConfigSchema = z
  .strictObject({
    maxCount: z
      .number()
      .optional()
      .meta({ description: 'Max bookmarks; saving past it drops the oldest. 0 = no limit.', default: 35 }),
  })
  .meta({ description: 'Bookmarks widget.', see: 'https://conness-gis.com/widgets/bookmarks/' })

The JSON Schema in app/schema/ that drives the hover help is regenerated from the schemas the next time npm run dev or npm run build starts. Commit it with your change.