Skip to content

Icons (Material + Lucide)

Your app needs a menu glyph, a lock in the password field, a back arrow? tempestweb ships two vendored icon setsLucide (the default, inherited from the core) and Material Symbols (Outlined) — drawn client-side as inline SVG. No icon font, no network, no CDN: it works offline and inside a PWA. 🎯

You pick the set in one obvious call, in typed Python:

from tempestweb.icons import material_icon, lucide_icon, MaterialIcons, Icons

material_icon(MaterialIcons.HOME)   # Material Symbols "home"
lucide_icon(Icons.MAIL)             # Lucide "mail"

The minimum: one icon on screen

An Icon is a widget like any other. Drop it in a Column/Row and you're done:

from dataclasses import dataclass

from tempest_core import App, Column, Row, Text, Widget

from tempestweb.icons import MaterialIcons, material_icon


@dataclass
class State:
    pass


def make_state() -> State:
    return State()


def view(app: App[State]) -> Widget:
    return Row(
        children=[
            material_icon(MaterialIcons.HOME),
            Text(content="Home"),
        ],
    )

Run it in all three modes — the drawing is identical:

tempestweb dev --mode wasm       # Python in the browser (Pyodide)
tempestweb dev --mode server     # Python on the server (FastAPI + WebSocket)
tempestweb dev --mode transpile  # app transcribed to native JS (static bundle)

Why two sets?

Lucide is the core's default set (tempest_core.icons.Icons) — clean, "feather"-style strokes. Material Symbols pairs with the always-on Material 3 base theme (see Theming). Use whichever matches your app; you can mix both on the same screen.

Picking an icon with autocomplete

MaterialIcons and Icons are StrEnums — each member is its string. So you get editor autocomplete without losing the freedom to pass a raw string:

from tempestweb.icons import MaterialIcons, material_icon

material_icon(MaterialIcons.SETTINGS)   # with autocomplete
material_icon("settings")               # raw string — identical

Any name in the set works

The enum lists the most common icons (HOME, SEARCH, CLOSE, MENU…), but any valid Material Symbols name works as a raw string as long as the glyph is vendored on the client (client/icons/material.js). For a glyph outside the set, see Custom icon below.

Size and color

size is the icon's edge in logical pixels. Omit it to let the icon scale with the surrounding font. Color comes from Style.color — the glyph is drawn in currentColor:

from tempest_core import Style
from tempest_core import Color

from tempestweb.icons import MaterialIcons, material_icon

# A 20px icon tinted red
material_icon(
    MaterialIcons.FAVORITE,
    size=20.0,
    style=Style(color=Color(r=220, g=40, b=40)),
)

# No size → follows the container's font size
material_icon(MaterialIcons.STAR)

Icons inside fields

The core's input widgets carry icon slots: Input, Dropdown and Autocomplete accept leading_icon and trailing_icon; IconButton and MenuItem accept icon. The name goes in raw — unprefixed it resolves to Lucide, for compatibility with the core's Icon:

An IconButton's label is its accessible name

An IconButton renders as a real <button>, with the icon's glyph inside and the label on aria-label — not as visible text. So IconButton(icon=Icons.MENU, label="menu", …) draws and is announced as "menu" instead of spelling the word out. An explicit semantics.label wins over label. True in all three modes and in the static HTML.

from tempest_core import Input


def email_input(value: str) -> Input:
    """Build an e-mail input with a leading mail glyph."""
    return Input(
        key="email",
        value=value,
        placeholder="you@example.com",
        leading_icon="mail",              # Lucide
        trailing_icon="material:check",   # Material, via the prefix
        on_change=lambda event: None,
    )

The ready-made fields do not expose the slot

EmailField and PasswordField (from tempestweb.components) wrap an Input but do not publish leading_icon/trailing_icon — passing the kwarg raises ValidationError naming the field, because the core rejects a kwarg it does not declare. Need an icon in the field? Use the core's Input directly, as above.

The name grammar

Under the hood, the set is encoded as a prefix on the Icon name: "material:home", "lucide:mail". The material_icon/lucide_icon helpers add the prefix for you. An unprefixed name ("mail") resolves to Lucide — which is why the icon slots only ask for the bare name.

Custom icon (raw SVG)

Need a glyph that is not vendored? Two ways out.

1. custom_icon — ship the path over the wire, register nothing. The SVG d rides in the icon name itself, so the client needs no prior registration. It's drawn stroked on a 0 0 24 24 grid in currentColor (the Lucide convention):

from tempestweb.icons import custom_icon

# A hand-drawn "bolt" on the 24x24 grid
custom_icon("M13 2 L4 14 H12 L11 22 L20 10 H13 Z", size=24.0)

2. Register on both sides — for a reused glyph. Register in Python (register_icon) and on the client (registerIcon in client/icons/index.js), then pass the bare name:

from tempest_core import Icon

from tempestweb.icons import register_icon

register_icon("rocket", "M13 2 L4 14 H12 L11 22 L20 10 H13 Z")
Icon(name="rocket")
import { registerIcon } from "./icons/index.js";

registerIcon("rocket", "M13 2 L4 14 H12 L11 22 L20 10 H13 Z");

When to use which

custom_icon is great for a single, one-off glyph (no client change). The both-sides registration is worth it when the same glyph shows up in many places — the path travels once (in the vendored JS) instead of in every patch.

Offline and PWA

Because everything is inline SVG from path data vendored in client/icons/{lucide,material}.js, the icons make no network requests. tempestweb build bundles those assets into the artifact, so an installed app (PWA) draws every icon offline, with no icon font and no external request. Nothing to configure. ✅

Recap

  • Two vendored sets: lucide_icon(...) (default) and material_icon(...) (pairs with the Material 3 theme).
  • MaterialIcons/Icons are StrEnums → autocomplete + raw string.
  • size scales the icon (omit to follow the font); Style.color tints it.
  • Field slots take a bare name (= Lucide).
  • Glyph outside the set: custom_icon(path) (one-off) or register_icon + registerIcon (reused).
  • It's all inline SVG — no network, offline/PWA safe.