Editable, draggable, styleable text placed on the map like a Marker — drag to move, double-click to edit, on-canvas resize/rotate handles, and a style toolbar; round-trips position/text/rotation/fontSize/color back to Dash.

TextMarker

Editable, draggable, styleable text placed on the map like a Marker — drag to move, double-click to edit, on-canvas resize/rotate handles, and a style toolbar; round-trips position/text/rotation/fontSize/color back to Dash.


Overview

dl2.TextMarker is editable, draggable, styleable text anchored to a [lat, lng] — used exactly like a dl2.Marker. Under the hood it is a Leaflet 2 Marker whose icon is a content-sized, optionally-contentEditable text box, so it pans and zooms with the basemap and behaves like a first-class map feature. It closes the "captions can't live on the map" gap from the text-caption-marker-proposal hand-off.

InteractionResult
Drag the labelmoves it; writes position back (+ bumps n_drags)
Double-clickinline-edit the text; commit on blur / Enter writes text back (+ n_edits)
Resize handle (corner)scales fontSize
Rotate handle (top)sets rotation (hold Shift to snap to 15°)
Style toolbar (while selected)font family / size, bold / italic, text & background color, rotation — each round-trips to Dash
Click the label / empty maptoggles selected (two-way, so a host can drive selection)

When position is omitted the label spawns at the center of the current viewport and writes that position back, so you can drop a caption with no coordinates and read where it landed.

The white anchor dot (which also doubles as the resize grip) is drawn at the chosen anchor — pick bottom and it sits at the bottom-center, top-left and it sits at the top-left, etc. — so you can always see where the label is pinned. center is special-cased to the bottom-right corner so the dot never covers the text.

Two size models via scaleWithZoom:

zoom (like a Tooltip).

so the caption keeps a fixed ground footprint as the camera flies (like a polygon's edge).

Live demo

Drag the "Fisherman's Wharf" caption, double-click to retype it, and use the on-canvas handles + glass toolbar — or drive every prop from the right column. The red "PIER 39" label has scaleWithZoom=True, so zoom in/out to watch it hold its ground size. The T tool in the top-right toolbar is the EditControl text tool (Route B): click it, click the map, and type — the caption is added to EditControl.geojson as a kind:"text" Point.

Route B — the EditControl text tool

dl2.EditControl gains a text tool alongside marker / polyline / polygon / …. Picking it and clicking the map drops an inline-editable caption that participates in the same geojson round-trip as every other shape — a GeoJSON Point carrying the caption + style in properties:

{
  "type": "Feature",
  "geometry": { "type": "Point", "coordinates": [-122.41, 37.808] },
  "properties": {
    "kind": "text", "text": "Fisherman's Wharf",
    "color": "#111827", "fontSize": 18,
    "fontFamily": "system-ui, sans-serif", "fontWeight": 600
  }
}

In edit mode the caption is draggable and double-click re-opens the inline editor. Enable it per-tool with draw={"text": True} (and disable the others to get a text-only toolbar).

The shape

A caption that places, styles, and round-trips like a Marker

# File: docs/text-marker/example.py  (region: minimal)

def _map():
    return dl2.Map(
        id="tm-map",
        center=CENTER,
        zoom=14,
        style={"height": "62vh"},
        children=[
            dl2.TileLayer(id="tm-tile", url=TILE_URL, attribution=ATTR),
            # A caption you place like a Marker: drag to move, double-click
            # to edit, and (when selected) resize / rotate with the
            # on-canvas handles.
            dl2.TextMarker(
                id="tm-cap",
                text="Fisherman's Wharf",
                position=CENTER,
                color="#0b3d66",
                fontSize=26,
                fontWeight=700,
                backgroundColor="rgba(255,255,255,0.6)",
                anchor="center",
                selected=True,
            ),
            # scaleWithZoom keeps a fixed GROUND size, so it grows on
            # screen as you zoom in.
            dl2.TextMarker(
                id="tm-geo",
                text="PIER 39",
                position=[37.8087, -122.4098],
                color="#c92a2a",
                fontSize=16,
                fontWeight=700,
                scaleWithZoom=True,
            ),
            # Route B — the EditControl 'text' tool: click the T, click the
            # map, type. Captions serialize as kind:"text" Point features in
            # EditControl.geojson.
            dl2.EditControl(
                id="tm-edit",
                position="topright",
                # Only expose the text tool here to keep the demo focused.
                draw={
                    "marker": False, "polyline": False, "polygon": False,
                    "rectangle": False, "circle": False, "circlemarker": False,
                    "text": True,
                },
            ),
        ],
    )

Source

# File: docs/text-marker/example.py

"""
TextMarker — editable, draggable, styleable map captions (limited working example).

The map hosts one selected `dl2.TextMarker` you can drag, double-click to edit, and
restyle with the on-canvas resize / rotate handles + the contextual toolbar — OR drive
every prop from the right column. A second caption has `scaleWithZoom=True` so it keeps a
fixed ground footprint as you zoom. The `EditControl`'s `text` tool (Route B) is wired in
too: pick the **T** tool, click the map, and type — the caption round-trips through the
same `geojson` channel as every drawn shape, as a `kind:"text"` Point.
"""

import dash_leaflet2 as dl2
import dash_mantine_components as dmc
from dash import Input, Output, callback, clientside_callback
from dl2_tiles import SATELLITE, register_theme_swap
from dl2_locations import SAN_FRANCISCO
from dl2_shared import info_panel

# Basemap pair for this page. dl2_tiles owns the light/dark wiring so
# every example themes the same way — see register_theme_swap below.
TILES = SATELLITE
TILE_URL = TILES.url("light")
ATTR = TILES.attribution()

CENTER = SAN_FRANCISCO.at(1.4, -1.3)  # Fisherman's Wharf

ANCHORS = [
    "top-left", "top", "top-right",
    "left", "center", "right",
    "bottom-left", "bottom", "bottom-right",
]



# region minimal
def _map():
    return dl2.Map(
        id="tm-map",
        center=CENTER,
        zoom=14,
        style={"height": "62vh"},
        children=[
            dl2.TileLayer(id="tm-tile", url=TILE_URL, attribution=ATTR),
            # A caption you place like a Marker: drag to move, double-click
            # to edit, and (when selected) resize / rotate with the
            # on-canvas handles.
            dl2.TextMarker(
                id="tm-cap",
                text="Fisherman's Wharf",
                position=CENTER,
                color="#0b3d66",
                fontSize=26,
                fontWeight=700,
                backgroundColor="rgba(255,255,255,0.6)",
                anchor="center",
                selected=True,
            ),
            # scaleWithZoom keeps a fixed GROUND size, so it grows on
            # screen as you zoom in.
            dl2.TextMarker(
                id="tm-geo",
                text="PIER 39",
                position=[37.8087, -122.4098],
                color="#c92a2a",
                fontSize=16,
                fontWeight=700,
                scaleWithZoom=True,
            ),
            # Route B — the EditControl 'text' tool: click the T, click the
            # map, type. Captions serialize as kind:"text" Point features in
            # EditControl.geojson.
            dl2.EditControl(
                id="tm-edit",
                position="topright",
                # Only expose the text tool here to keep the demo focused.
                draw={
                    "marker": False, "polyline": False, "polygon": False,
                    "rectangle": False, "circle": False, "circlemarker": False,
                    "text": True,
                },
            ),
        ],
    )
# endregion


component = dmc.Stack(
    [
        dmc.Grid(
            [
                dmc.GridCol(
                    dmc.Paper(
                        _map(),
                        shadow="sm",
                        radius="md",
                        withBorder=True,
                        style={"overflow": "hidden", "height": "62vh"},
                    ),
                    span=8,
                ),
                dmc.GridCol(
                    dmc.Stack(
                        [
                            info_panel(
                                "Caption text",
                                dmc.TextInput(id="tm-text", value="Fisherman's Wharf"),
                            ),
                            info_panel(
                                "Typography",
                                dmc.Stack(
                                    [
                                        dmc.ColorInput(id="tm-color", value="#0b3d66", format="hex"),
                                        dmc.Text("Font size", size="xs", c="dimmed"),
                                        dmc.Slider(id="tm-size", min=10, max=64, value=26),
                                        dmc.Text("Rotation", size="xs", c="dimmed"),
                                        dmc.Slider(
                                            id="tm-rot", min=-180, max=180, value=0,
                                            marks=[{"value": 0, "label": "0°"}],
                                        ),
                                    ],
                                    gap="xs",
                                ),
                            ),
                            info_panel(
                                "Anchor",
                                dmc.SegmentedControl(
                                    id="tm-anchor",
                                    data=[{"label": a, "value": a} for a in ANCHORS],
                                    value="center",
                                    orientation="vertical",
                                    fullWidth=True,
                                    size="xs",
                                ),
                            ),
                            info_panel(
                                "Geographic sizing",
                                dmc.Switch(
                                    id="tm-scale",
                                    label="scaleWithZoom (fixed ground size)",
                                    checked=False,
                                ),
                            ),
                            info_panel(
                                "Live readback",
                                dmc.Code(id="tm-out", block=True, children="…"),
                            ),
                        ],
                        gap="md",
                    ),
                    span=4,
                ),
            ],
            gutter="md",
        ),
    ],
    gap="md",
)


# ---- controls -> component (one-way drivers; the on-map UI is the other path) ----------
@callback(Output("tm-cap", "text"), Input("tm-text", "value"))
def _text(v):
    return v or ""


@callback(Output("tm-cap", "color"), Input("tm-color", "value"))
def _color(v):
    return v or "#0b3d66"


@callback(Output("tm-cap", "fontSize"), Input("tm-size", "value"))
def _size(v):
    return int(v or 26)


@callback(Output("tm-cap", "rotation"), Input("tm-rot", "value"))
def _rot(v):
    return int(v or 0)


@callback(Output("tm-cap", "anchor"), Input("tm-anchor", "value"))
def _anchor(v):
    return v or "center"


@callback(Output("tm-cap", "scaleWithZoom"), Input("tm-scale", "checked"))
def _scale(checked):
    return bool(checked)


# ---- component -> readback panel -------------------------------------------------------
@callback(
    Output("tm-out", "children"),
    Input("tm-cap", "position"),
    Input("tm-cap", "text"),
    Input("tm-cap", "rotation"),
    Input("tm-cap", "fontSize"),
    Input("tm-cap", "n_edits"),
    Input("tm-cap", "n_drags"),
    Input("tm-edit", "geojson"),
)
def _readback(pos, text, rot, size, n_edits, n_drags, geo):
    n_captions = sum(
        1
        for f in (geo or {}).get("features", [])
        if (f.get("properties") or {}).get("kind") == "text"
    )
    return (
        f"position : {pos}\n"
        f"text     : {text!r}\n"
        f"rotation : {rot}\n"
        f"fontSize : {size}\n"
        f"n_edits  : {n_edits or 0}\n"
        f"n_drags  : {n_drags or 0}\n"
        f"edit-tool captions: {n_captions}"
    )


# ---- light/dark tile swap (standard pattern) ------------------------------------------
register_theme_swap("tm-tile", TILES)

Source: /text-marker

Note for AI agents: This is the static, prerendered view of an interactive Dash application served because we detected a non-JS user agent. Full prose docs: