Buttons¶
Buttons are the most basic tappable affordance in tempest-core. There are two:
Button (with text) and IconButton (icon-only). Both share the
Chakra-ergonomics variant API (variant / size / color_scheme) anchored
on Material 3 — you describe the intent and a pure resolver produces the
concrete Style from the Theme tokens. 🚀
What you'll learn here
- The four variants and which M3 treatment each lowers to.
- The four sizes and why a small button never breaks the touch target.
- How
color_schemepicks the color family. - How a button resolves and bakes its
Style, and how to read the per-state table.
Button¶
A tappable button with a text label. In the minimal case you pass only label —
everything else has a sane default (SOLID / MD / primary):
That single Button(label="Save") is already a filled, primary, medium-density
button — ready for the renderers. Pass an on_click to react to the tap:
from tempest_core import Button
save = Button(
label="Save",
on_click=lambda e: app.set_state(saving=True), # (1)!
variant="solid",
size="md",
color_scheme="primary",
)
- The handler may be sync or
async— the runtime schedules awaitables on the event loop. It receives aTapEvent(see API reference).
Props¶
| Prop | Type | Default | What it does |
|---|---|---|---|
label |
str |
(required) | The text shown on the button. |
on_click |
EventHandler \| None |
None |
Handler invoked on tap; sync or async. Receives TapEvent. |
variant |
Variant |
SOLID |
The visual treatment (solid / outline / ghost / link). |
size |
Size \| dict[str, Size] |
MD |
The density — a single Size or a per-breakpoint map. |
color_scheme |
str |
"primary" |
The M3 role family (primary / secondary / tertiary / error / neutral). |
theme |
Theme |
Theme() |
The theme whose tokens resolve the variant. Kept out of the IR. |
media |
MediaQueryData \| None |
None |
Viewport snapshot for a responsive size. Kept out of the IR. |
theme and media are build-time inputs, not IR props
Both are used only when resolving the Style and are excluded from the
node props (prop_exclude_names). A full Theme per node would bloat the tree
and the serialized bridge payload — the resolved style already carries their
effect.
Variants¶
variant picks the emphasis; the H1 resolver (resolve_variant) maps it to a
Material 3 treatment. color_scheme then decides which color family paints that
treatment.
Variant |
M3 treatment | Background | Content | Border |
|---|---|---|---|---|
SOLID |
filled button | role color | legible on_* |
— |
OUTLINE |
outlined button | transparent | role color | same as role color |
GHOST |
text button (no underline) | transparent | role color | — |
LINK |
inline underlined text | transparent | role color | — |
from tempest_core import Button, Row
bar = Row(
children=[
Button(label="Save", variant="solid"), # highest emphasis
Button(label="Cancel", variant="outline"), # medium emphasis
Button(label="Skip", variant="ghost"), # low emphasis
Button(label="Learn more", variant="link"), # inline
]
)
Emphasis scale
SOLID → OUTLINE → GHOST → LINK is a highest-to-lowest emphasis
scale. Reserve SOLID for the screen's primary action; use GHOST for
secondary actions that shouldn't compete with it.
Sizes and the touch target¶
size accepts a single Size (XS / SM / MD / LG) or a per-breakpoint
map for responsive density:
from tempest_core import Button
from tempest_core import Size
# Compact on mobile, roomy from the "md" breakpoint up.
responsive = Button(label="Submit", size={"base": Size.SM, "md": Size.LG})
A small button lowers density, never the touch target
Every size guarantees min_height = 48.0 (MIN_TOUCH_TARGET, Material's 48dp
target). Shrinking size reduces the visual padding/typography, but the
tappable area never drops below 48dp — motor accessibility is preserved by
construction.
How the Style is resolved and baked¶
At construction, Button runs a model_validator(mode="after") that:
- captures your explicit
styleas the override; - resolves the base
Stylefromvariant/size/color_schemeagainst thetheme(viaresolve_variant); - merges the override on top of the base (the override's set fields win);
- bakes the result into
.style, so the renderers consume a plainStyle, unaware any resolution happened.
from tempest_core import Button
from tempest_core import Style
# The override wins on the fields it sets; the rest comes from the resolved variant.
custom = Button(label="Danger", color_scheme="error", style=Style(radius=999.0))
Override always on top
This keeps backward compatibility: Button(label=...) with no style gives
the variant button; Button(label=..., style=…) hand-styles on top. You never
lose the variant by setting a single field.
Per-state table (hover / press / disabled / focus)¶
resolve_variant is pure and lives in the engine — but real buttons have
interaction states. The state_styles() method returns the resolved Style
for each ComponentState, with the caller's override merged on top:
from tempest_core import Button
from tempest_core import ComponentState
button = Button(label="Save", color_scheme="primary")
states = button.state_styles()
states[ComponentState.DEFAULT] # at rest
states[ComponentState.HOVER] # pointer over (M3 state layer)
states[ComponentState.PRESSED] # being tapped
states[ComponentState.DISABLED] # inactive (reduced opacity)
states[ComponentState.FOCUS] # keyboard/reader focus
Resolution is pure; only the event→state mapping lives in the renderer
The core produces the state table deterministically. Applying the Material 3 state layer on the right pointer/focus event is the only part that lives in the renderers (Qt / Compose) — keeping the core from ever touching pixels.
IconButton¶
An icon-only, square/circular button. It is button-shaped, so it reuses
resolve_variant exactly like Button — then pins width/height to the
resolved min_height (a square box at least 48dp) and a circular radius,
using only existing Style fields (no new field). It defaults to the GHOST
variant (the lowest-emphasis, icon-forward treatment):
from tempest_core import IconButton
close = IconButton(icon="close", label="Close dialog") # (1)!
settings = IconButton(icon="settings", color_scheme="primary", label="Open settings")
iconis a curatedIconsvalue (or its string), or an arbitrary platform icon name.
Props¶
| Prop | Type | Default | What it does |
|---|---|---|---|
icon |
Icons \| str |
(required) | The icon — curated Icons value or platform name. |
on_click |
EventHandler \| None |
None |
Handler on tap; sync or async. |
variant |
Variant |
GHOST |
The visual treatment — defaults to GHOST. |
size |
Size \| dict[str, Size] |
MD |
The density. |
color_scheme |
str |
"primary" |
The M3 role family. |
label |
str |
"" |
The accessible name (a11y / Semantics) of the text-less button. |
theme |
Theme |
Theme() |
The theme. Kept out of the IR. |
media |
MediaQueryData \| None |
None |
Viewport snapshot. Kept out of the IR. |
Always pass label on an IconButton
An icon-only button has no visible text, so label carries the accessible
name (contentDescription / accessible label) that renderers route into the
node's accessibility surface. Without it, the button is mute to screen readers.
It isn't optional in practice — it's accessibility.
Square/circular geometry¶
IconButton pins its geometry via _squareify: width and height take the
resolved min_height (≥ 48dp), and radius takes half of it (a circle). All with
existing Style fields — the touch area never drops below 48dp, just like
Button. The per-state table (state_styles()) returns each state already pinned
to that geometry.
Recap¶
- Two buttons, one API:
Button(text) andIconButton(icon) resolve viaresolve_variantfromvariant/size/color_scheme. - Variants:
SOLID(filled) →OUTLINE(outlined) →GHOST(text) →LINK(inline), highest to lowest emphasis. - Sizes:
XS/SM/MD/LGor a per-breakpoint map;min_heightguarantees the 48dp touch target at any size. - Resolved & baked style: the caller's override is always merged on top of the resolved variant.
state_styles()gives the per-ComponentStatetable; only the event→state mapping lives in the renderer.IconButtonis square/circular,GHOSTby default, and requireslabelfor accessibility.