Build Powerful TUI Apps in Python with Textual and Rich

Terminal apps used to mean raw curses calls and a lot of pain. Today, Python’s Textual
and Rich
libraries have flipped that experience entirely. In under 50 lines of Python you can have a full-screen app with styled layouts, interactive widgets, keyboard navigation, and live data updates - no web browser, no Electron, no JavaScript. This post walks through both libraries, shows you how they fit together, and builds up to a complete working example you can extend immediately.
Why TUIs Are Worth Your Attention Again
Terminal user interfaces fell out of fashion when web apps took over in the 2000s. For a long time, anything that needed a real UI went to a browser. The terminal was left to quick CLI scripts and the occasional top instance.
That calculus has shifted. A growing portion of serious development work happens in SSH sessions, inside containers, or on headless servers where spinning up a browser is not an option. Fast developer tooling like lazygit , k9s , and nnn show that TUIs can be genuinely faster and more ergonomic than their GUI equivalents for keyboard-centric workflows. And on the Python side, the Rich and Textual libraries from Will McGugan have removed the historical excuse of “TUIs are too hard to build.”

The distinction between the two libraries is worth getting clear upfront:
- Rich is a library for rendering beautiful output in the terminal. Tables, progress bars, syntax-highlighted code, styled text, trees, log panels - Rich makes all of these trivial in any Python script. You are still writing a script; Rich just makes the output look excellent.
- Textual is a full application framework built on Rich. It gives you a component model (App + Widgets), CSS-like stylesheets, reactive state management, an async event loop, mouse support, and a dev console. You use Textual when you need an interactive, full-screen application rather than one-shot output.
Most projects start with Rich and graduate to Textual when they need interactivity. Both install from PyPI and have no system dependencies beyond a reasonably modern terminal emulator - iTerm2, Alacritty, Kitty, Windows Terminal, or any VTE-based terminal on Linux all work correctly.
Getting Beautiful Terminal Output with Rich
Even if you never build a full TUI, Rich is worth installing for every Python project. The output quality improvement over plain print() is dramatic with minimal extra code.
pip install richBasic Usage
Replace print with Rich’s Console object and your strings support markup:
from rich.console import Console
from rich.markup import escape
console = Console()
console.print("[bold cyan]Deployment started[/bold cyan]")
console.print(f"Server: [green]{escape(hostname)}[/green]")
console.print("[red]Error:[/red] connection refused", style="on dark_red")The markup syntax uses [style]text[/style] tags. The escape() function is important when printing user-supplied data - it prevents markup injection, which matters for tools that display external content like log files or API responses.
Tables, Panels, and Trees
from rich.table import Table
from rich.panel import Panel
from rich import print as rprint
table = Table(title="Benchmark Results")
table.add_column("Model", style="cyan")
table.add_column("Tokens/s", justify="right", style="green")
table.add_column("VRAM (GB)", justify="right")
table.add_row("Qwen 2.5 7B", "82", "5.1")
table.add_row("Llama 3.2 3B", "134", "2.8")
table.add_row("Mistral 7B v0.3", "78", "5.3")
rprint(Panel(table, title="RTX 5070 - ollama 0.5.1"))Rich handles column widths, borders, and alignment automatically. The Panel widget draws a titled box around any renderable content.
Progress Bars
Rich provides a Progress context manager that renders live updating progress bars to stderr without interfering with stdout:
from rich.progress import Progress, SpinnerColumn, TimeElapsedColumn
import time
with Progress(SpinnerColumn(), *Progress.get_default_columns(), TimeElapsedColumn()) as progress:
task = progress.add_task("Processing files...", total=1000)
for i in range(1000):
time.sleep(0.001)
progress.advance(task)Syntax Highlighting
from rich.syntax import Syntax
code = '''
def fibonacci(n: int) -> int:
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
'''
syntax = Syntax(code, "python", theme="monokai", line_numbers=True)
console.print(syntax)Rich uses Pygments under the hood and supports all Pygments-recognized languages and themes.
Building Your First Textual App
Once you need interactivity - buttons, inputs, selectables, live updating panels, keyboard shortcuts - Textual is the right tool.
pip install textualA Textual app has three core concepts:
- App - the root class that defines the overall structure and runs the event loop
- Widgets - reusable UI components (buttons, inputs, tables, custom components)
- Screens - named “pages” that the App can push and pop, similar to navigation stacks in mobile apps
Here is a minimal but complete Textual app:
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Button, Label
from textual.containers import Vertical
class CounterApp(App):
CSS = """
Vertical {
align: center middle;
}
Label {
padding: 1 2;
border: round $primary;
width: auto;
}
Button {
margin: 1;
}
"""
count = 0
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Label(str(self.count), id="counter")
yield Button("Increment", id="increment", variant="primary")
yield Button("Reset", id="reset", variant="warning")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "increment":
self.count += 1
elif event.button.id == "reset":
self.count = 0
self.query_one("#counter", Label).update(str(self.count))
if __name__ == "__main__":
CounterApp().run()The compose() method uses yield to build the widget tree declaratively. The CSS class variable holds inline Textual CSS. Event handlers are methods named on_{widget_type}_{event} - Textual routes them automatically based on naming convention.
Reactive State and the Event System
Manual UI updates like the query_one().update() call above work fine for simple cases. For anything more complex, Textual’s reactive descriptors automatically trigger UI updates when data changes, similar to how useState works in React components.
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Header, Footer, Label, Input
from textual.containers import Vertical
class LivePreviewApp(App):
CSS = """
Label#preview {
border: round $accent;
padding: 1 2;
margin: 1;
height: 3;
}
"""
user_text: reactive[str] = reactive("")
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Input(placeholder="Type something...", id="text_input")
yield Label("", id="preview")
yield Footer()
def on_input_changed(self, event: Input.Changed) -> None:
self.user_text = event.value
def watch_user_text(self, value: str) -> None:
preview = self.query_one("#preview", Label)
preview.update(f"Preview: [bold]{value}[/bold]" if value else "Start typing above...")The watch_user_text method fires automatically whenever self.user_text changes. No explicit update() calls in the event handler needed - the reactive system handles the connection. This separation of “state changed” from “update the UI” keeps logic clean as apps grow.
Background Workers
Textual has a Worker API for running async or threaded tasks without blocking the UI. This is the right pattern for API calls, file I/O, or long computations:
from textual.app import App, ComposeResult
from textual.widgets import Button, Log
from textual.worker import Worker
import asyncio
class FetchApp(App):
def compose(self) -> ComposeResult:
yield Button("Start task", id="start")
yield Log(id="output")
def on_button_pressed(self, event: Button.Pressed) -> None:
self.run_worker(self.fetch_data(), exclusive=True)
async def fetch_data(self) -> None:
log = self.query_one(Log)
for i in range(10):
await asyncio.sleep(0.5)
log.write_line(f"Step {i + 1} complete")run_worker spawns the coroutine without blocking the event loop. The UI stays responsive and the Log widget updates in real time as the worker produces output.
CSS Styling and Layouts
Textual’s styling system is one of its most distinctive features. It is not quite CSS, but it borrows the same mental model - selectors, properties, inheritance - so if you have any web experience it clicks quickly.
Styles live either in the CSS class variable (good for small apps) or in a separate .tcss file loaded with CSS_PATH:
/* app.tcss */
Screen {
background: $surface;
}
#sidebar {
width: 30;
border-right: solid $primary;
padding: 1;
}
#main {
width: 1fr;
padding: 1 2;
}
Button.danger {
background: $error;
color: $text;
}
Button.danger:hover {
background: $error-darken-1;
}Selectors work on widget IDs (#sidebar), widget types (Button), CSS classes added with add_class(), and pseudo-classes like :hover, :focus, and :disabled. Dimensions use either fixed values (30 means 30 terminal cells) or fractional units (1fr means “take all remaining space”).
The DevTools - activated by running textual devtools in a second terminal while your app is running - provide a live inspector similar to browser developer tools. You can click on any widget to see its computed styles, modify CSS properties live, and see the widget tree. This dramatically shortens the style iteration loop compared to restart-and-check workflows.
Dark and light modes are built in. Textual ships with a $primary, $secondary, $error, $success color system that adapts automatically. Toggle with:
def on_key(self, event: events.Key) -> None:
if event.key == "d":
self.dark = not self.darkBuilding a Practical Tool: File Size Viewer
Here is a complete, functional TUI tool that scans a directory and displays file sizes in a sortable table - the kind of tool you might actually keep around:
from pathlib import Path
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, DataTable, Input, Label
from textual.containers import Vertical
class FileSizeApp(App):
TITLE = "File Size Viewer"
CSS = """
Input { margin-bottom: 1; }
DataTable { height: 1fr; }
"""
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Input(value=str(Path.cwd()), placeholder="Directory path", id="path_input")
yield Label("", id="status")
yield DataTable(id="file_table")
yield Footer()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_columns("Name", "Size", "Type")
table.cursor_type = "row"
self.scan_directory(Path.cwd())
def on_input_submitted(self, event: Input.Submitted) -> None:
target = Path(event.value)
if target.is_dir():
self.scan_directory(target)
else:
self.query_one("#status", Label).update("[red]Not a valid directory[/red]")
def scan_directory(self, path: Path) -> None:
table = self.query_one(DataTable)
table.clear()
entries = []
try:
for item in sorted(path.iterdir()):
size = item.stat().st_size if item.is_file() else 0
kind = "dir" if item.is_dir() else item.suffix or "file"
entries.append((item.name, size, kind))
except PermissionError:
self.query_one("#status", Label).update("[red]Permission denied[/red]")
return
entries.sort(key=lambda x: x[1], reverse=True)
for name, size, kind in entries:
size_str = f"{size:,}" if size else "-"
table.add_row(name, size_str, kind)
self.query_one("#status", Label).update(f"[green]{len(entries)} entries in {path}[/green]")
if __name__ == "__main__":
FileSizeApp().run()Save it as file_viewer.py and run with python file_viewer.py. You get a sortable, navigable file size breakdown with keyboard navigation built in - no curses, no ncurses bindings, no C extension wrestling.

Real-World Use Cases
The combination of Rich for formatting and Textual for interaction covers a wide range of internal tooling scenarios:
Database and API tooling - Query browsers that show results in Rich tables, REST API testers, Redis key explorers. The DataTable widget handles thousands of rows with virtual scrolling.
Log viewers - Textual’s Log widget streams text with automatic scrolling. Pair it with a Worker that tails a file and you have a filterable, searchable log watcher in under 100 lines.
AI and LLM interfaces - A terminal chat interface for local models via Ollama fits naturally into the Textual model: Input for the prompt, Markdown widget for rendering responses (Textual ships with a Markdown renderer), Worker to stream tokens as they arrive.
Deployment and ops dashboards - Health check panels, job queue monitors, container status views. If you are already deploying Python infrastructure code, a Textual dashboard is trivial to add and much faster to iterate on than setting up Grafana.

Git workflow tools - Commit selectors, interactive rebasing UIs, diff viewers. The Textual project itself ships Posting , a full HTTP client, and Toolong , a log file viewer, as showcase applications worth studying.
For getting started, the Textual documentation
is thorough and ships with runnable examples for every widget. Run textual demo after installing to see a gallery of all built-in widgets. The community Discord linked from the docs is active and the maintainers respond quickly to issues.
Testing Textual apps is also well-supported. The framework ships with a Pilot test helper that lets you simulate key presses, button clicks, and input events in pytest without starting a display. This makes it practical to write integration tests for TUI tooling the same way you would for any other Python application.
Terminal apps built with Textual ship as standard Python packages. Your users install with pip install your-tool and run from any terminal without touching a web browser or installing an Electron runtime. For developer tooling in particular, that deployment story is hard to beat.