Interactive forms
Use Textual's Input, Button, and Checkbox widgets with the reactive attribute pattern and event handlers.
- Describe the Input, Button, and Checkbox widgets
- Explain the reactive attribute pattern and how it drives re-renders
- Wire widgets together using on_button_pressed and on_input_changed
Textual widgets are the building blocks of interactive forms: Input captures
text, Button triggers actions, Checkbox toggles state. What makes them
powerful is the reactive attribute system — when an attribute decorated with
reactive() changes, the app automatically re-renders the parts of the UI that
depend on it.
The reactive attribute pattern
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Header, Footer, Static
class CounterApp(App):
count: reactive[int] = reactive(0)
def compose(self) -> ComposeResult:
yield Header()
yield Static(id="display")
yield Footer()
def watch_count(self, new_value: int) -> None:
"""Called automatically when count changes."""
self.query_one("#display", Static).update(
f"Count: {new_value}"
)reactive(0) declares count as a reactive integer with default 0. The method
watch_count follows the naming convention watch_<attribute> — textual calls
it whenever the attribute's value changes. No manual refresh, no event emission;
the change propagates automatically.
Input — capturing text
Input renders a text field. The on_input_changed event fires on every
keystroke; on_input_submitted fires when the user presses Enter:
from textual.widgets import Input, Static
from textual.app import App, ComposeResult
class SearchApp(App):
def compose(self) -> ComposeResult:
yield Input(placeholder="Type to search...", id="query")
yield Static(id="result")
def on_input_changed(self, event: Input.Changed) -> None:
query = event.value
self.query_one("#result", Static).update(
f"Searching for: {query!r}"
)event.value contains the current text in the input field. The handler fires
after every character, so the result updates live.
Button — triggering actions
Button renders a clickable button. The on_button_pressed event carries the
button's id so one handler can distinguish multiple buttons:
from textual.widgets import Button, Static
from textual.app import App, ComposeResult
class ConfirmApp(App):
def compose(self) -> ComposeResult:
yield Static("Confirm action?", id="prompt")
yield Button("Yes", id="yes", variant="success")
yield Button("No", id="no", variant="error")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "yes":
self.query_one("#prompt", Static).update("Confirmed.")
else:
self.query_one("#prompt", Static).update("Cancelled.")variant controls the button colour. Textual ships with primary, success,
warning, and error variants.
Checkbox — toggling state
Checkbox renders a labelled toggle. on_checkbox_changed fires when the state
flips:
from textual.widgets import Checkbox, Static
from textual.app import App, ComposeResult
class OptionsApp(App):
def compose(self) -> ComposeResult:
yield Checkbox("Enable verbose output", id="verbose")
yield Checkbox("Write log file", id="log")
yield Static("Options: none selected", id="summary")
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
verbose = self.query_one("#verbose", Checkbox).value
log = self.query_one("#log", Checkbox).value
selected = [
k for k, v in [("verbose", verbose), ("log", log)] if v
]
label = ", ".join(selected) if selected else "none selected"
self.query_one("#summary", Static).update(f"Options: {label}")Textual's event naming convention is consistent: every widget event is a class
nested inside the widget class — Button.Pressed, Input.Changed,
Checkbox.Changed. The handler method name is on_ followed by the snake_case
of the full event name: on_button_pressed, on_input_changed,
on_checkbox_changed.
Where to go next
Next: live dashboards — updating a DataTable on a timer with set_interval
to build a self-refreshing display.