Theming (Material 3)¶
Your widgets are good-looking from the start. A bare Button becomes a Material
3 filled button — pill shape, primary fill, a state layer on hover, elevation. A
bare Input becomes an outlined field with an animated focus. You write no
CSS for any of it. ✨
This is the always-on base theme that landed in 0.6.0: a small Material 3
stylesheet (client/theme.js) injected once, at mount, that gives every app
sensible typography, spacing and accented controls — even one you never styled. And
when you want to break out of the defaults, the widget's inline Style always
wins.
Where the style comes from (tempest-core ≥ 0.8.1)
Each Button/Input's resting look — fill, border, shape and color — now
comes from tempest-core's variant system, resolved inline by the widget
itself. client/theme.js handles only what inline style cannot express:
the hover/focus/press state layer (::before), the focus ring and the font
family. The filled_button/tonal_button/… helpers are an MD3-named façade
over the core variants. You still get the Material 3 look with zero CSS.
The minimum: rely on the base theme¶
There is nothing to configure. Write the app normally; the base theme installs itself.
from dataclasses import dataclass
from tempest_core import App, Button, Column, Input, Text, Widget
@dataclass
class State:
name: str = ""
def make_state() -> State:
return State()
def view(app: App[State]) -> Widget:
def set_name(event) -> None:
app.set_state(lambda s: setattr(s, "name", event.value))
return Column(
children=[
Text(content="What's your name?"),
Input(value=app.state.name, on_change=set_name, key="name"),
Button(label=f"Hello, {app.state.name or 'world'}!", key="hello"),
],
)
Run it in all three modes — it looks 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)
What you just got for free:
- Typography — the
Roboto/system-uifamily instead of the browser's Times New Roman, onText,ButtonandInput. - Button — a filled pill in the primary color, a translucent state layer on hover/focus/press, and animated elevation.
- Field — a rounded outlined
Inputwhose border thickens and recolors to the primary tone on focus. - Checkbox — a box sized and accented with the primary color.
Why a stylesheet and not inline Style?
Inline CSS cannot express :hover, :focus-visible, :active or :disabled
— the very states that make a control feel modern. Those live in the base
sheet, keyed off the data-tw-type attribute the DOM renderer stamps on every
element.
Overriding the theme: inline Style wins¶
The base sheet is a floor, not a cage. Because it uses no !important and a
widget's Style becomes an inline style="" on the element, your declarations beat
the cascade. The interaction states (hover/focus) keep working on top.
from tempest_core import Button, Style
from tempest_core import Color
# The pill, the typography and the state layer stay — only the color changes.
Button(
label="Buy now",
style=Style(background=Color.from_hex("#0b57d0")),
key="buy",
)
Global rebrand via tokens
The theme tokens are CSS custom properties on :root (--tw-primary,
--tw-surface, --tw-outline, …). To re-theme the whole UI without touching a
single widget, override them from your own <style> on the host page:
Elevation with Style(shadow=...)¶
In 0.6.0, a Shadow on a widget's Style becomes a real CSS box-shadow on
the web — the same elevation the native renderers (Qt/Compose) draw. The mapping is
direct: offset_x offset_y blur color.
from tempest_core import Column, Text, Widget
from tempest_core import Color, Edge, Shadow, Style
def card(content: str) -> Widget:
return Column(
children=[Text(content=content)],
style=Style(
background=Color.from_hex("#ffffff"),
radius=12.0,
padding=Edge.all(16.0),
shadow=Shadow(
color=Color(r=0, g=0, b=0, a=0.3),
blur=3.0,
offset_x=0.0,
offset_y=1.0,
),
),
key="card",
)
This emits box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.3). A Shadow with no explicit
color falls back to a neutral translucent black, so an elevation still reads even
without picking a tint.
The same MD3 elevation levels
The base sheet defines --tw-elevation-1 and --tw-elevation-2 (umbra +
penumbra) and applies them to the filled button on hover/press. When you want a
card or button with its own elevation, use Style(shadow=...) — the numbers
above (blur=3, offset_y=1) are exactly the resting shadow of
elevated_button.
Material 3 button variants¶
You don't have to remember which colors compose a tonal or outlined button.
tempestweb.components ships all five MD3 variants as one-line helpers:
from tempest_core import App, Row, Widget
from tempestweb.components import (
elevated_button,
filled_button,
outlined_button,
text_button,
tonal_button,
)
def view(app: App[State]) -> Widget:
def save() -> None:
app.set_state(lambda s: s)
return Row(
children=[
filled_button("Save", on_click=save, key="save"),
tonal_button("Duplicate", key="dup"),
elevated_button("Export", key="export"),
outlined_button("Edit", key="edit"),
text_button("Cancel", key="cancel"),
],
)
| Helper | Emphasis | How it's built |
|---|---|---|
filled_button |
High (default) | A bare button — the base theme gives the full filled look |
tonal_button |
Medium | A secondary container fill + on-container text, flat |
elevated_button |
Medium | A light surface + primary text + a resting shadow |
outlined_button |
Medium | An outline + primary label, transparent fill |
text_button |
Low | Just the primary label, no fill or outline |
How the variants tell themselves apart from filled
filled_button is a Button with no inline Style, so the base theme
supplies everything. The other variants get a small Style (background / color
/ border / shadow). Setting an inline background is also the signal the base
sheet uses to opt a variant out of the filled button's automatic
elevation — that's why tonal/outlined/text stay flat while elevated_button
carries its own shadow.
Themed fields¶
The tempestweb-native fields — TextField, EmailField, PasswordField — use a
bare Input with no inline Style on purpose, precisely so the base sheet
renders them as light, outlined fields consistent with the rest of the UI. A muted
label sits above and a red error line appears when you pass error.
from tempest_core import App, Column, Widget
from tempestweb.components import EmailField, PasswordField, validate_email
def view(app: App[State]) -> Widget:
def set_email(value: str) -> None:
app.set_state(lambda s: setattr(s, "email", value))
def set_password(value: str) -> None:
app.set_state(lambda s: setattr(s, "password", value))
return Column(
children=[
EmailField(
value=app.state.email,
on_change=set_email,
error=validate_email(app.state.email) or "",
key="email",
),
PasswordField(
value=app.state.password,
on_change=set_password,
key="password",
),
],
)
More on fields and forms
The fields and the ready-made forms (LoginForm, SignupForm, the BR fields)
have their own page in Ready-made components. Here the focus is
only on how the theme makes them look finished without styling anything.
Rebranding by token, from Python¶
The base sheet paints everything from --tw-* custom properties on
:root, and those are how an app changes its look — without touching a
single widget. What was missing was the middle: you build the palette in
Python and need it on the page.
from tempest_core import Theme, ThemeMode
from tempest_core import Color
from tempestweb.html import theme_css
def head() -> str:
"""Build the head markup that rebrands the whole interface.
Returns:
str: A style element carrying the app's palette.
"""
theme = Theme.from_seed(Color(r=39, g=58, b=79), mode=ThemeMode.SYSTEM)
return f"<style>{theme_css(theme)}</style>"
Theme.from_seed generates the 39 Material 3 roles from one seed colour —
light and dark — and theme_css emits the ones the sheet actually reads.
The block goes in the <head>, before the base sheet is installed at
mount; since it declares the same names at the same specificity, yours wins
by coming later.
Dark mode comes free, through the sheet's own switch
A SYSTEM theme emits the light scheme on :root and the dark one under
:root[data-tw-theme="dark"] — the same selector the base sheet uses,
so your palette and the sheet's tokens flip together, in the mode the app
resolved. A theme pinned to DARK emits its dark scheme under both
selectors: it paints before any attribute arrives and still outranks the
sheet once it does.
No media query is ever emitted. See "Why not prefers-color-scheme" below:
a widget never sees the OS, so darkening the page from the OS left a light
tree on a dark background — and the inline half is the half that wins.
Only what the sheet consumes
theme_css emits the variables the base sheet reads, not all 39 roles.
A variable nothing consumes looks thorough and is debt: the next reader
has to go to the CSS to find out whether it does anything.
Declare the theme, and the host delivers it¶
The snippet above builds the CSS by hand because that used to be the only way.
There is a shorter one now, and it covers both halves: declare THEME next to
your view.
# app.py
from dataclasses import dataclass
from tempest_core import App, Theme, Widget
from tempest_core import Color
@dataclass
class State:
"""The app's state."""
def make_state() -> State:
"""Build the initial state."""
return State()
def view(app: App[State]) -> Widget:
"""Build the screen."""
...
#: The brand palette. The artifact reads it and delivers it to both ends.
THEME: Theme = Theme.from_seed(seed=Color(r=39, g=58, b=79))
The generated artifact — Mode B's and Mode A's alike — passes that THEME to the
app when it builds it, and that matters because components resolve colour in
Python: a filled button carries its own fill as an inline style. A theme that
never reaches the tree is a theme that never paints, however many tokens the page
carries.
The two ends the host covers:
- The tree — the theme goes to the
App, so every component is born with your palette. - The page — the
--tw-*tokens the base sheet reads. In Mode B they are written into the<head>at render time; in Mode A the page is static and the app only exists once Pyodide is up, so the CSS is injected at boot, before the first mount.
Theme(primary=...) is not the same as Theme.from_seed(...)
A Theme carries a token set (tokens) plus a few loose convenience
fields (primary, background, …). Components read the tokens. Building
a theme by filling in only the loose fields leaves the whole tree on the
baseline palette — which was exactly the bug in the theme-switcher example,
whose buttons stayed purple while the swatch said teal. Use
Theme.from_seed, or build the TokenSet explicitly.
A runtime theme repaints components, not the page's tokens
app.set_theme(...) rebuilds the tree, so everything that resolves colour in
Python follows immediately. The --tw-* tokens, though, are written once —
they follow the declared THEME. In practice: widget colours change, and the
states only the sheet can express (hover, focus) stay on the declared palette.
If runtime switching is the heart of your app, declare THEME with the
palette it opens on.
Dark mode: pass the theme to the widget¶
A styled widget resolves its own colours from the theme it carries — from its
theme field, not from an ambient theme. Which is why the idiom is one line:
Pass app.theme and the whole tree follows app.set_theme(...); leave it out and
the widget resolves the light palette, even with the app in dark mode. The
same in all three modes.
from tempest_core import App, Card, Column, Text, Theme, ThemeMode, Widget
def view(app: App[State]) -> Widget:
"""Draw a card that follows the app's theme."""
theme: Theme = app.theme
return Column(
key="body",
children=[
Card(
key="card",
theme=theme,
children=[Text(content="Follows the theme", key="label")],
),
],
)
def go_dark(app: App[State]) -> None:
"""Swap the app's theme, which re-resolves every widget that got it."""
app.set_theme(Theme(mode=ThemeMode.DARK))
A layout widget has no theme
Row, Column and Text carry no colour of their own, so the core gives
them no such field — passing theme= raises ValidationError naming it. The
colour they show is the one they inherit from the styled box around them.
A component propagates the way the core propagates
An EmailInput is the field: it hands its theme to the Input it builds.
A SearchBar composes one and layers the style it resolved on top, so the
inner field keeps the default palette — and Mode C reproduces that distinction
component by component, pinned by a parity matrix in both modes.
tempestweb's own components speak the same idiom¶
TextField, EmailField, PasswordField, LoginForm and SignupForm (from
tempestweb.components) take a theme like any widget, and hand it to
everything they build — each field's Input, each form's fields, the submit
button, and the colour of the label and the error line:
from tempestweb.components import LoginForm
LoginForm(
email=app.state.email,
password=app.state.password,
on_email_change=set_email,
on_password_change=set_password,
on_submit=sign_in,
theme=app.theme,
key="login",
)
Before 0.101.0 they were light by construction
None of the five declared a theme, and none passed one down, so a dark app
got a light field with no warning at all — and the worst case is a dark
background (from the base sheet) under dark text, i.e. unreadable. If you
were following the theme=app.theme idiom on every widget, these five were
the ones ignoring it.
Stepper takes a theme too, since tempest-core 0.16.0
It was the sixth component of
#158 and the
only one living in tempest-core, so it waited on a release there. It now
declares theme, variant, color_scheme and size like the rest of the
kit, and the value reads the theme's ON_SURFACE role. No component in the
parity matrix sits outside the dark axis any more — LIGHT_ONLY_COMPONENTS is
empty, and the guard stays for the next component born without a theme.
A SearchBar or a TextField composes one and layers the style it
resolved on top, so the inner field keeps the default palette — and Mode C
reproduces that distinction component by component, pinned by a parity matrix
in both modes.
Mode C: the generated tables carry a mode axis since 0.99.0
Mode C has no Python, so each widget's resolved style travels as a generated
table. Up to 0.98.0 those tables were baked from the default theme: every
transpiled widget and component rendered light, and since an inline style
beats the stylesheet, the half with precedence was the half that failed. The
table now carries both modes and the builder picks by theme.is_dark().
The base sheet follows the mode you declare¶
The Style the core resolves travels inline on each widget, and inline beats
the stylesheet. But half the screen is not inline: the page background, a field's
surface, its ::placeholder, every :hover/:focus state, an overlay's surface.
That is CSS — and the CSS had no mode axis, so a dark app showed a white field
inside a dark card.
Now it has one. The renderer marks the document with the resolved mode:
and the base sheet redefines its tokens under that selector. You write nothing for
this: in Mode B (and SSE) the server sends a theme envelope; in Mode A the
runtime calls the callback directly; in Mode C set_theme marks the document
in-process.
Declared dark? Pass app.theme to the widgets
The marking follows the app's theme; each widget's colour follows the
theme that widget received. If you call
app.set_theme(Theme(mode=DARK)) and do not pass theme=app.theme to the
widgets, the sheet goes dark and the widgets stay light — measured: an Input
with no theme ends up with a dark background (sheet) and dark text (inline),
i.e. unreadable. Pass the theme; it is the core's own rule.
Why not prefers-color-scheme
It would be the obvious answer — and it would be wrong. A widget built with
Theme(mode=SYSTEM) resolves light in the core: it never sees the OS.
Darkening the sheet from the OS alone would put a light tree on a dark page. If
you want to follow the OS, read app.media.platform_dark_mode in your view
and call set_theme — then both halves move together.
The palette's pairs are contrast-gated
axe's color-contrast rule needs real layout, so the a11y gate disables it and
the Lighthouse job is a soft signal — which is how an illegible dark palette
could have shipped unnoticed. What does not need layout is the pair of
roles: --tw-on-surface is by definition what goes on --tw-surface.
tests/client/theme-contrast.test.js computes the 12 pairs the sheet promises,
in both modes, and fails below AA. Tightest pair today: warning on surface
in light mode, 6.02:1 against a 4.5 minimum.
The first light is not sent
The sheet's tokens are the light palette, so marking light on mount would spend a frame saying what the CSS already says. Every later change is sent, including the return to light.
Progress indicators¶
ProgressBar and Spinner have no intrinsic size: with no stylesheet both
render as an empty zero-height div — present in the tree, invisible on screen,
which is worse than absent, because the app claims to be showing progress and
the user sees nothing. The base theme draws them, and color_scheme picks the
accent from the families the core names (primary, secondary, tertiary,
error, success, warning, info, neutral).
from tempest_core import App, Column, Widget
from tempest_core import ProgressBar, Spinner
def view(app: App[State]) -> Widget:
return Column(
children=[
ProgressBar(value=0.42, key="reading"),
ProgressBar(indeterminate=True, key="queued"),
ProgressBar(value=1.0, color_scheme="success", key="done"),
Spinner(size=24.0, key="busy"),
],
)
A determinate bar is a track with a percentage-width fill, and the theme's
transition makes the width glide to each new value. An indeterminate one
declares no value — neither in CSS nor to a screen reader, which gets
role="progressbar" without aria-valuenow, because a number about work nobody
is measuring would be read out as fact.
Rebrand like everything else
The accent comes from --tw-indicator, which reads the family's token.
Override --tw-success (or any other) and every bar in that family follows,
without touching a single widget.
Motion is decoration; state is not
Under prefers-reduced-motion: reduce the animation stops and the
indeterminate bar stays a static band — a reader who asked for less motion
still sees that something is running.
SSR draws inline
render_to_html ships no base stylesheet, only a reset, so there both
widgets carry self-contained inline style: a translucent track and a
currentColor fill, meaning the bar takes the colour of the text around it.
That is what makes a static page show progress without depending on any CSS
of yours.
Recap¶
- The Material 3 base theme is always on — typography, spacing and accented controls come ready, with no per-widget styling.
- The widget's inline
Stylealways wins over the base sheet (no!important); the hover/focus states keep working on top. theme_css(Theme.from_seed(...))builds that block for you, dark mode included.- Re-theme the whole UI by overriding the
--tw-*tokens from a<style>on the page. Style(shadow=...)becomes a CSSbox-shadowon the web, matching the native renderers.filled_button/tonal_button/elevated_button/outlined_button/text_buttonare the five MD3 variants, one line each.TextField/EmailField/PasswordFieldinherit the outlined field from the theme.ProgressBarandSpinnerare only on screen because the theme draws them;color_schemepicks the accent and SSR emits them with inline style of their own.- Everything renders the same in Mode A (WASM) and Mode B (server).