textual
Textual is a Python framework for building full terminal user interfaces: real apps with buttons, data tables, tree views, text areas, and mouse support, running inside the terminal or served to a browser via textual serve. You compose widgets in Python, style them with a CSS dialect (TCSS), and react to messages and key bindings, a model lifted deliberately from web development. It sits on top of Rich (same author) and is async under the hood without forcing async on your code.
The most capable and best-documented way to build serious terminal UIs in Python, and genuinely fun to use. Accept the churn of frequent majors and the post-2025 slower maintenance reality before betting a long-lived product on it; for simple prompts and output, smaller tools do the job.
Use it if
- You are building an interactive terminal tool (dashboard, DB client, log explorer) that outgrew print statements and input() prompts
- Your users live over SSH or in headless environments where shipping an Electron or web app is not an option
- You want one codebase that runs in the terminal and in a browser, which textual serve gives you for free
- You want a real testing story for TUIs; run_test() with a Pilot object can simulate keys and clicks in CI without a terminal
- You need long-term API calm: the project is on major version 8 after roughly four years, and breaking changes between majors are routine, so pin carefully and expect migration work on upgrades
- Maintenance risk matters to you: Textualize, the company built around the framework, wound down in 2025, and the project now moves at a part-time open source pace (last push about three and a half weeks before this review, with hundreds of open issues and PRs)
- You only need prompts, progress bars, or pretty output: questionary or Rich alone does that without an app framework, an event loop, and a CSS system
- You need dense custom graphics, plotting, or pixel control; a terminal cell grid is the wrong canvas and a web UI will fight you less
- Your tool must also run as plain piped output in scripts; a full-screen app takes over the terminal, so you end up maintaining a separate non-interactive mode anyway
Setup reality
pip install textual is pure Python and painless; add textual-dev for the dev console you will definitely need, because print() debugging does not work when your app owns the terminal (textual console in a second terminal captures logs and prints). The real cost is the learning curve: compose(), message handlers, reactive attributes, workers for anything blocking, and TCSS files are their own mental model, closer to learning a small web framework than to using click. Terminal quirks (color depth, emoji width, Windows terminals) still leak through on odd setups.
Patterns
Smallest useful appminimal-app
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class HelloApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Static("Hello, terminal")
yield Footer()
if __name__ == "__main__":
HelloApp().run()run() takes over the whole terminal until quit (ctrl+q by default). compose() yields widgets in document order; layout beyond stacking comes from containers and TCSS.
Style widgets with Textual CSSstyle-with-tcss
from textual.app import App, ComposeResult
from textual.widgets import Static
class StyledApp(App):
CSS = """
Screen { align: center middle; }
#banner {
width: 50%;
padding: 1 2;
background: $primary;
border: heavy $accent;
}
"""
def compose(self) -> ComposeResult:
yield Static("Centered and boxed", id="banner")Use CSS_PATH = "app.tcss" for a separate file; with the textual-dev console running, edits to that file hot-reload live. Selectors are widget class names, #id, and .class, like the web but not identical.
React to button presseshandle-button-press
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Button, Static
class ButtonsApp(App):
def compose(self) -> ComposeResult:
yield Button("Go", id="go", variant="primary")
yield Static("", id="status")
@on(Button.Pressed, "#go")
def start(self) -> None:
self.query_one("#status", Static).update("started")The @on decorator with a selector beats one giant on_button_pressed handler full of if-chains. query_one raises if the selector matches nothing, which surfaces typos early.
Reactive state that updates the UIreactive-attributes
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Static
class Counter(Static):
count = reactive(0)
def watch_count(self, value: int) -> None:
self.update(f"count: {value}")
def on_click(self) -> None:
self.count += 1Assigning to a reactive triggers watch_<name> and a refresh automatically. Mutating a list inside a reactive does not trigger it; reassign a new list or call mutate_reactive.
Run blocking work without freezing the UIbackground-work
import httpx
from textual import work
from textual.app import App
class WeatherApp(App):
@work(exclusive=True)
async def fetch(self, city: str) -> None:
async with httpx.AsyncClient() as client:
r = await client.get(URL, params={"q": city})
self.query_one("#out").update(r.text)
def on_input_changed(self, event) -> None:
self.fetch(event.value)Anything slow in a message handler freezes the whole app. Use @work; for sync/blocking functions add thread=True, and exclusive=True cancels the previous worker, which is exactly right for type-ahead.
Show tabular datadata-table
from textual.app import App, ComposeResult
from textual.widgets import DataTable
ROWS = [("ada", 36), ("grace", 45)]
class TableApp(App):
def compose(self) -> ComposeResult:
yield DataTable()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("name", "age")
table.add_rows(ROWS)
table.cursor_type = "row"Populate in on_mount, not compose; the widget must exist first. Handle DataTable.RowSelected for enter/click on a row. Tens of thousands of rows are fine; it renders lazily.
Key bindings with footer hintskey-bindings
from textual.app import App
from textual.widgets import Footer
class MyApp(App):
BINDINGS = [
("d", "toggle_dark", "Dark mode"),
("q", "quit", "Quit"),
("r", "refresh_data", "Refresh"),
]
def action_refresh_data(self) -> None:
...A binding maps to an action_<name> method; the third tuple element is the label the Footer widget displays. Widgets can define their own BINDINGS that apply when focused.
Modal confirmation dialogmodal-screen
from textual.app import App
from textual.screen import ModalScreen
from textual.widgets import Button, Label
class ConfirmScreen(ModalScreen[bool]):
def compose(self):
yield Label("Delete this item?")
yield Button("Yes", id="yes")
yield Button("No", id="no")
def on_button_pressed(self, event) -> None:
self.dismiss(event.button.id == "yes")
# in the app:
# if await self.push_screen_wait(ConfirmScreen()): ...Screens are typed on their dismiss value. push_screen_wait must run inside a worker; the callback form push_screen(screen, callback) works from ordinary handlers.
Update on a timerperiodic-updates
from datetime import datetime
from textual.app import App, ComposeResult
from textual.widgets import Digits
class ClockApp(App):
def compose(self) -> ComposeResult:
yield Digits("")
def on_ready(self) -> None:
self.set_interval(1, self.tick)
def tick(self) -> None:
self.query_one(Digits).update(f"{datetime.now():%T}")set_interval returns a Timer you can pause or stop, and it does not fire while the app is suspended. Keep the callback fast; slow work still belongs in a worker.
Drive the app in teststest-with-pilot
import pytest
@pytest.mark.asyncio
async def test_counter():
app = CounterApp()
async with app.run_test() as pilot:
await pilot.press("up", "up", "up")
await pilot.click("#reset")
assert app.query_one(Counter).count == 0run_test runs the real app headless, so CI needs no terminal tricks. Call await pilot.pause() after actions that post messages to let the queue drain before asserting.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| rich | PyPI | You want beautiful static output, tables, and progress bars without an interactive app; it is also Textual's own foundation. |
| urwid | PyPI | You want the battle-hardened classic TUI toolkit and can accept a much older API style. |
| prompt-toolkit | PyPI | You are building REPLs, autocompleting prompts, or editor-like input rather than widget-based full-screen apps. |
| questionary | PyPI | You just need polished interactive prompts (select, confirm, text) in an otherwise ordinary CLI. |