Intermediate
Terminal applications have come a long way from blinking cursors and plain text. Today’s Python developers can build dashboards, file explorers, database browsers, and data pipelines with rich color, interactive widgets, and keyboard navigation — all running inside a standard terminal window. The challenge has always been that building these UIs from scratch with curses is tedious, platform-inconsistent, and extremely difficult to maintain.
Python’s Textual framework solves this with a modern, batteries-included TUI (terminal user interface) toolkit. It brings CSS-inspired styling, a widget library, reactive data binding, and an async event system to the terminal. Install it with pip install textual and you are writing rich TUIs in minutes.
In this article we cover installing Textual and creating your first app, using built-in widgets (Button, Input, DataTable), applying CSS styles to your layout, handling keyboard and click events, and building a real-world task manager TUI. By the end you will have the skills to build professional terminal applications entirely in Python.
Textual TUI: Quick Example
Here is the minimal Textual app — a clickable button that updates a label. Run it with python quick_textual.py and use your keyboard or mouse to interact.
# quick_textual.py
from textual.app import App, ComposeResult
from textual.widgets import Button, Static
class QuickApp(App):
def compose(self) -> ComposeResult:
yield Static("Hello from Textual!", id="message")
yield Button("Click Me", id="btn")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.query_one("#message", Static).update("Button clicked!")
if __name__ == "__main__":
QuickApp().run()
Output:
[Terminal renders a centered message and a clickable button]
Hello from Textual!
[ Click Me ]
After clicking:
Button clicked!
The compose() method returns the widget tree for your app — this is where you declare the UI structure. The on_button_pressed method is an event handler that fires whenever any Button is pressed. The query_one method finds a widget by CSS-style selector and calls update() to change its content. Everything runs asynchronously inside Textual’s built-in event loop.
What Is Textual and Why Use It?
Textual is a Python framework built by the creators of Rich for building feature-rich TUI applications that run in any terminal. It uses an async-first architecture, a CSS-inspired styling system, and a reactive data model that automatically updates the UI when your data changes.
| Feature | curses | urwid | Textual |
|---|---|---|---|
| CSS styling | No | No | Yes |
| Mouse support | Limited | Yes | Yes |
| Async-native | No | No | Yes |
| Widget library | None | Basic | Rich |
| Testing tools | None | None | Built-in |
Textual is the right choice when you want a professional-quality TUI without the complexity of curses, especially if your app involves real-time data updates, interactive forms, or complex layouts.
Installing Textual
Textual is on PyPI and supports Python 3.8 and later. It includes the Rich library for rendering:
# Install Textual
pip install textual
# For development -- includes the Textual DevTools for live CSS editing
pip install textual-dev
The textual-dev package adds the textual CLI with a live development mode: run textual run --dev your_app.py and changes to your CSS reload instantly without restarting the app. This speeds up UI development significantly.
Built-in Widgets
Textual ships with a comprehensive widget library. Here is a form using Input, Button, and Label together:
# widgets_demo.py
from textual.app import App, ComposeResult
from textual.widgets import Button, Input, Label
from textual.containers import Vertical
class FormApp(App):
CSS = """
Vertical { align: center middle; }
Input { margin: 1; width: 40; }
Button { margin: 1; }
Label { margin: 1; color: green; }
"""
def compose(self) -> ComposeResult:
with Vertical():
yield Label("Enter your name:", id="prompt")
yield Input(placeholder="Type here...", id="name_input")
yield Button("Submit", variant="primary", id="submit")
yield Label("", id="result")
def on_button_pressed(self, event: Button.Pressed) -> None:
name = self.query_one("#name_input", Input).value
result = self.query_one("#result", Label)
if name:
result.update(f"Hello, {name}!")
else:
result.update("[red]Please enter a name.[/red]")
if __name__ == "__main__":
FormApp().run()
Output:
[Terminal shows a centered form]
Enter your name:
[_____________________ ]
[ Submit ]
After typing "Alice" and clicking Submit:
Hello, Alice!
The CSS class variable lets you write inline CSS that controls layout, dimensions, colors, and alignment. The Vertical container stacks widgets top-to-bottom, while align: center middle centers the column both horizontally and vertically in the terminal. Rich markup like [red]...[/red] works inside Label text for quick inline styling.
Interactive DataTable
The DataTable widget renders sortable, scrollable tabular data with keyboard navigation — ideal for displaying structured data like database results or log files:
# datatable_demo.py
from textual.app import App, ComposeResult
from textual.widgets import DataTable
ROWS = [
("Name", "Language", "Stars"),
("FastAPI", "Python", "80k"),
("Textual", "Python", "25k"),
("htmx", "JavaScript", "38k"),
("Rust", "Rust", "95k"),
]
class TableApp(App):
def compose(self) -> ComposeResult:
yield DataTable()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns(*ROWS[0])
for row in ROWS[1:]:
table.add_row(*row)
if __name__ == "__main__":
TableApp().run()
Output:
Name Language Stars
FastAPI Python 80k
Textual Python 25k
htmx JavaScript 38k
Rust Rust 95k
The on_mount event fires once when the app starts and the widget tree is ready — the right place to populate data. add_columns takes column names as arguments, and add_row takes cell values. Press the arrow keys to navigate rows and columns, and press Enter to select a row (the DataTable.RowSelected event fires).
Reactive Attributes
Textual’s reactive system automatically re-renders parts of the UI whenever a tracked attribute changes — no manual refresh calls needed:
# reactive_demo.py
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Button, Static
class CounterApp(App):
count: reactive[int] = reactive(0)
def compose(self) -> ComposeResult:
yield Static(id="counter")
yield Button("+1", id="inc")
yield Button("Reset", id="reset")
def watch_count(self, value: int) -> None:
self.query_one("#counter", Static).update(f"Count: {value}")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "inc":
self.count += 1
elif event.button.id == "reset":
self.count = 0
if __name__ == "__main__":
CounterApp().run()
Output:
[Terminal shows counter updating in real time]
Count: 0
[+1] [Reset]
After clicking +1 three times:
Count: 3
The reactive descriptor declares a tracked attribute. Whenever self.count changes, Textual automatically calls the corresponding watch_count watcher method, which updates the Static widget. This pattern keeps your UI in sync with your data without any manual glue code — similar to how React state works, but for the terminal.
Real-Life Example: Terminal Task Manager
Here is a complete task manager TUI that lets you add tasks, mark them complete, and delete them — all from the keyboard:
# task_manager.py
from textual.app import App, ComposeResult
from textual.widgets import Button, DataTable, Input, Label
from textual.containers import Horizontal, Vertical
class TaskApp(App):
CSS = """
#top { height: 5; }
#input { width: 40; margin: 1; }
#add { margin: 1; }
#tasks { height: 1fr; }
#status { color: green; margin: 1; }
"""
def __init__(self):
super().__init__()
self.tasks = []
self.next_id = 1
def compose(self) -> ComposeResult:
with Vertical():
with Horizontal(id="top"):
yield Input(placeholder="New task...", id="input")
yield Button("Add", variant="primary", id="add")
yield Label("", id="status")
yield DataTable(id="tasks")
def on_mount(self) -> None:
table = self.query_one("#tasks", DataTable)
table.add_columns("ID", "Task", "Done")
def on_button_pressed(self, event: Button.Pressed) -> None:
inp = self.query_one("#input", Input)
text = inp.value.strip()
if not text:
return
task = {"id": self.next_id, "text": text, "done": False}
self.tasks.append(task)
table = self.query_one("#tasks", DataTable)
table.add_row(str(task["id"]), task["text"], "[ ]")
inp.value = ""
self.next_id += 1
self.query_one("#status", Label).update(f"Task {task['id']} added")
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
table = self.query_one("#tasks", DataTable)
row_key = event.row_key
task_id = int(table.get_cell(row_key, "ID"))
task = next((t for t in self.tasks if t["id"] == task_id), None)
if task:
task["done"] = not task["done"]
done_str = "[x]" if task["done"] else "[ ]"
table.update_cell(row_key, "Done", done_str)
if __name__ == "__main__":
TaskApp().run()
Output:
[Terminal shows a task manager interface]
[___New task____________] [Add]
ID Task Done
1 Write tests [ ]
2 Update README [x]
3 Deploy to prod [ ]
Task 2 added
This app demonstrates several Textual patterns working together: a composed layout using Horizontal and Vertical containers, reactive input handling, DataTable row selection with on_data_table_row_selected, and real-time status updates. You can extend it by adding a delete button, persistent storage to a JSON file, or priority sorting.
Frequently Asked Questions
Does Textual work in all terminals?
Textual works best in modern terminals that support 256-color or true-color output and Unicode. It runs well in iTerm2, Windows Terminal, GNOME Terminal, and most other modern terminals. The classic Windows cmd.exe has limited support. You can check compatibility by running textual diagnose from the command line, which reports your terminal’s capabilities.
Do I need to know asyncio to use Textual?
Basic Textual apps work fine without explicit asyncio knowledge — Textual’s event system handles concurrency automatically. When you need to run slow operations (like network requests or file I/O) without blocking the UI, use async def for your handlers and await for async operations. Textual’s worker system also provides a simple self.run_in_thread() method for running blocking code in the background.
Can I put CSS in a separate file?
Yes. Set the class variable CSS_PATH = "app.tcss" to load Textual CSS from an external file. This is the recommended approach for larger apps and enables the live-reload feature in textual-dev, where CSS changes apply instantly to the running app without restarting. Textual CSS uses a subset of CSS syntax with additional terminal-specific properties like scrollbar-gutter.
How do I test a Textual app?
Textual ships with a testing framework built on pytest. Use async with App.run_test() as pilot to get a pilot object that can simulate clicks (pilot.click("#button")), key presses (pilot.press("enter")), and pause for rendering (await pilot.pause()). You can then assert on widget states by querying the live DOM.
How do I distribute a Textual app?
Package it as any standard Python script or library. For a standalone executable, use PyInstaller to bundle the app with its dependencies. Textual’s pure-Python nature means no C extensions are required, making cross-platform distribution straightforward. You can also publish to PyPI and let users install with pip install yourapp.
Conclusion
Textual brings the ergonomics of modern web UI development to the terminal. We covered creating a basic app with compose(), using built-in widgets including Button, Input, Label, and DataTable, writing inline CSS for layout and styling, using reactive attributes for automatic UI updates, and handling events with named listener methods. The task manager example showed how these pieces combine into a complete, interactive application.
Try extending the task manager with priority levels, due dates, and JSON file persistence to save tasks between sessions. Or build a log viewer that tails a file in real time using a background worker and reactive updates.
The official Textual documentation is at textual.textualize.io, with full widget reference, CSS property docs, and a gallery of example apps.